# Page Header 03

> Invoice list header with a live count, a New invoice dialog and a toolbar for search, status filter, sort and list or grid layout. Shows an empty result with Clear filters when nothing matches. Ships composable parts.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/page-header-03).

- Page, with a live preview: https://shadcn.exchange/r/hirael/page-header-03
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/page-header-03-light-d4a45f94fc6df5d6.png
- Registry item: https://hirael.com/r/page-header-03.json
- Categories: blocks, page-header
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/page-header-03.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `badge`, `button`, `dialog`, `empty`, `field`, `input`, `input-group`, `select`, `toggle-group`

## Files

### `registry/hirael/bases/radix/blocks/page-header-03/page-header-03.tsx`

```tsx
// Page Header 03 from Hirael <https://hirael.com/blocks/page-header/page-header-03>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { LayoutGrid, List, Plus, Search, X } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/registry/hirael/bases/radix/ui/dialog';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@/registry/hirael/bases/radix/ui/empty';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/registry/hirael/bases/radix/ui/field';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/registry/hirael/bases/radix/ui/input-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/registry/hirael/bases/radix/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

type ListHeaderProps = React.ComponentProps<'header'>;

const ListHeader = ({ className, ...props }: ListHeaderProps) => {
  return <header data-slot="list-header" className={cn('flex flex-col gap-6', className)} {...props} />;
};

type ListHeaderRowProps = React.ComponentProps<'div'>;

const ListHeaderRow = ({ className, ...props }: ListHeaderRowProps) => {
  return (
    <div
      data-slot="list-header-row"
      className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}
      {...props}
    />
  );
};

type ListHeaderContentProps = React.ComponentProps<'div'>;

const ListHeaderContent = ({ className, ...props }: ListHeaderContentProps) => {
  return <div data-slot="list-header-content" className={cn('flex min-w-0 flex-col gap-1', className)} {...props} />;
};

type ListHeaderTitleProps = React.ComponentProps<'h1'>;

const ListHeaderTitle = ({ className, ...props }: ListHeaderTitleProps) => {
  return (
    <h1
      data-slot="list-header-title"
      className={cn('text-2xl font-semibold tracking-tight sm:text-3xl', className)}
      {...props}
    />
  );
};

type ListHeaderCountProps = React.ComponentProps<'p'>;

/** Live result count; announced politely when filters change it. */
const ListHeaderCount = ({ className, ...props }: ListHeaderCountProps) => {
  return (
    <p
      data-slot="list-header-count"
      aria-live="polite"
      className={cn('text-sm text-muted-foreground tabular-nums', className)}
      {...props}
    />
  );
};

type ListHeaderActionsProps = React.ComponentProps<'div'>;

const ListHeaderActions = ({ className, ...props }: ListHeaderActionsProps) => {
  return (
    <div data-slot="list-header-actions" className={cn('flex shrink-0 items-center gap-2', className)} {...props} />
  );
};

type ListHeaderToolbarProps = React.ComponentProps<'div'>;

const ListHeaderToolbar = ({ className, ...props }: ListHeaderToolbarProps) => {
  return (
    <div
      role="toolbar"
      data-slot="list-header-toolbar"
      className={cn('flex flex-col gap-2 border-y border-border py-3 md:flex-row md:items-center', className)}
      {...props}
    />
  );
};

export {
  ListHeader,
  ListHeaderRow,
  ListHeaderContent,
  ListHeaderTitle,
  ListHeaderCount,
  ListHeaderActions,
  ListHeaderToolbar,
};

const EASE = 'ease-[cubic-bezier(0.22,1,0.36,1)]';
const ENTER = `animate-in fade-in slide-in-from-bottom-4 duration-500 ${EASE} fill-mode-both motion-reduce:animate-none`;
const SWAP = `animate-in fade-in slide-in-from-bottom-2 duration-300 ${EASE} fill-mode-both motion-reduce:animate-none`;

const stagger = (index: number, step = 60): React.CSSProperties => ({ animationDelay: `${index * step}ms` });

type Status = 'paid' | 'due' | 'overdue';
type StatusFilter = 'all' | Status;
type Sort = 'newest' | 'oldest' | 'amount-desc' | 'amount-asc' | 'due';
type View = 'list' | 'grid';

interface Invoice {
  id: string;
  customer: string;
  amount: number;
  status: Status;
  issued: string;
  due: string;
}

const TODAY = '2026-09-24';

const INVOICES: readonly Invoice[] = [
  {
    id: 'INV-2041',
    customer: 'Alder Logistics',
    amount: 4280,
    status: 'paid',
    issued: '2026-07-20',
    due: '2026-08-19',
  },
  {
    id: 'INV-2042',
    customer: 'Brightline Dental',
    amount: 1150,
    status: 'paid',
    issued: '2026-07-22',
    due: '2026-08-21',
  },
  {
    id: 'INV-2043',
    customer: 'Cobalt Studio',
    amount: 3600,
    status: 'overdue',
    issued: '2026-07-25',
    due: '2026-08-24',
  },
  {
    id: 'INV-2044',
    customer: 'Driftwood Coffee',
    amount: 640,
    status: 'paid',
    issued: '2026-07-28',
    due: '2026-08-27',
  },
  {
    id: 'INV-2045',
    customer: 'Evergreen Legal',
    amount: 7920,
    status: 'paid',
    issued: '2026-07-31',
    due: '2026-08-30',
  },
  {
    id: 'INV-2046',
    customer: 'Fieldnote Press',
    amount: 1875.5,
    status: 'overdue',
    issued: '2026-08-03',
    due: '2026-09-02',
  },
  {
    id: 'INV-2047',
    customer: 'Granite Health',
    amount: 12400,
    status: 'paid',
    issued: '2026-08-05',
    due: '2026-09-04',
  },
  { id: 'INV-2048', customer: 'Harbor & Pine', amount: 2310, status: 'paid', issued: '2026-08-08', due: '2026-09-07' },
  {
    id: 'INV-2049',
    customer: 'Ironbark Supply',
    amount: 5045.75,
    status: 'overdue',
    issued: '2026-08-11',
    due: '2026-09-10',
  },
  { id: 'INV-2050', customer: 'Juniper Labs', amount: 980, status: 'paid', issued: '2026-08-14', due: '2026-09-13' },
  {
    id: 'INV-2051',
    customer: 'Kestrel Aviation',
    amount: 18750,
    status: 'overdue',
    issued: '2026-08-18',
    due: '2026-09-17',
  },
  { id: 'INV-2052', customer: 'Lumen Optics', amount: 2640, status: 'paid', issued: '2026-08-21', due: '2026-09-20' },
  { id: 'INV-2053', customer: 'Meridian Travel', amount: 3125, status: 'due', issued: '2026-08-25', due: '2026-09-24' },
  {
    id: 'INV-2054',
    customer: 'Northgate Realty',
    amount: 4400,
    status: 'due',
    issued: '2026-08-28',
    due: '2026-09-27',
  },
  { id: 'INV-2055', customer: 'Orchard Bakery', amount: 520, status: 'paid', issued: '2026-08-31', due: '2026-09-30' },
  {
    id: 'INV-2056',
    customer: 'Parallel Robotics',
    amount: 9860,
    status: 'due',
    issued: '2026-09-02',
    due: '2026-10-02',
  },
  { id: 'INV-2057', customer: 'Quarry Fitness', amount: 1390, status: 'due', issued: '2026-09-05', due: '2026-10-05' },
  {
    id: 'INV-2058',
    customer: 'Redwood Analytics',
    amount: 6275,
    status: 'paid',
    issued: '2026-09-08',
    due: '2026-10-08',
  },
  { id: 'INV-2059', customer: 'Saltmarsh Films', amount: 2980, status: 'due', issued: '2026-09-10', due: '2026-10-10' },
  {
    id: 'INV-2060',
    customer: 'Tidewater Marine',
    amount: 7150,
    status: 'due',
    issued: '2026-09-12',
    due: '2026-10-12',
  },
  {
    id: 'INV-2061',
    customer: 'Umber Architects',
    amount: 3480,
    status: 'due',
    issued: '2026-09-15',
    due: '2026-10-15',
  },
  {
    id: 'INV-2062',
    customer: 'Vantage Clinics',
    amount: 11200,
    status: 'due',
    issued: '2026-09-17',
    due: '2026-10-17',
  },
  {
    id: 'INV-2063',
    customer: 'Willow Pediatrics',
    amount: 1760,
    status: 'due',
    issued: '2026-09-19',
    due: '2026-10-19',
  },
  { id: 'INV-2064', customer: 'Yardline Sports', amount: 2245, status: 'due', issued: '2026-09-22', due: '2026-10-22' },
];

const STATUS_OPTIONS: readonly { value: StatusFilter; label: string }[] = [
  { value: 'all', label: 'All statuses' },
  { value: 'paid', label: 'Paid' },
  { value: 'due', label: 'Due' },
  { value: 'overdue', label: 'Overdue' },
];

const SORT_OPTIONS: readonly { value: Sort; label: string }[] = [
  { value: 'newest', label: 'Newest first' },
  { value: 'oldest', label: 'Oldest first' },
  { value: 'due', label: 'Due soonest' },
  { value: 'amount-desc', label: 'Highest amount' },
  { value: 'amount-asc', label: 'Lowest amount' },
];

const STATUS_LABEL: Record<Status, string> = { paid: 'Paid', due: 'Due', overdue: 'Overdue' };

const STATUS_DOT: Record<Status, string> = {
  paid: 'bg-success',
  due: 'bg-warning',
  overdue: 'bg-destructive',
};

const SORTERS: Record<Sort, (a: Invoice, b: Invoice) => number> = {
  newest: (a, b) => b.issued.localeCompare(a.issued) || b.id.localeCompare(a.id),
  oldest: (a, b) => a.issued.localeCompare(b.issued) || a.id.localeCompare(b.id),
  due: (a, b) => a.due.localeCompare(b.due),
  'amount-desc': (a, b) => b.amount - a.amount,
  'amount-asc': (a, b) => a.amount - b.amount,
};

const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const moneyRounded = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });

const formatDate = (date: string) =>
  new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(date));

const addDays = (date: string, days: number) => {
  const next = new Date(`${date}T00:00:00Z`);
  next.setUTCDate(next.getUTCDate() + days);

  return next.toISOString().slice(0, 10);
};

const InvoiceStatus = ({ status }: { status: Status }) => (
  <Badge variant="outline" data-slot="invoice-status" data-status={status}>
    <span aria-hidden className={cn('size-1.5 rounded-full', STATUS_DOT[status])} />
    {STATUS_LABEL[status]}
  </Badge>
);

const PageHeader03 = () => {
  const [invoices, setInvoices] = React.useState<readonly Invoice[]>(INVOICES);
  const [query, setQuery] = React.useState('');
  const [status, setStatus] = React.useState<StatusFilter>('all');
  const [sort, setSort] = React.useState<Sort>('newest');
  const [view, setView] = React.useState<View>('list');

  const needle = query.trim().toLowerCase();
  const filtered = invoices
    .filter((invoice) => status === 'all' || invoice.status === status)
    .filter(
      (invoice) =>
        needle === '' ||
        invoice.customer.toLowerCase().includes(needle) ||
        invoice.id.toLowerCase().includes(needle) ||
        money.format(invoice.amount).includes(needle),
    )
    .sort(SORTERS[sort]);

  const filtering = needle !== '' || status !== 'all';
  const outstanding = invoices
    .filter((invoice) => invoice.status !== 'paid')
    .reduce((sum, invoice) => sum + invoice.amount, 0);

  const clearFilters = () => {
    setQuery('');
    setStatus('all');
  };

  const create = (customer: string, amount: number) => {
    const lastNumber = Math.max(...invoices.map((invoice) => Number(invoice.id.slice(4))));
    setInvoices((current) => [
      ...current,
      {
        id: `INV-${lastNumber + 1}`,
        customer,
        amount,
        status: 'due',
        issued: TODAY,
        due: addDays(TODAY, 30),
      },
    ]);
    clearFilters();
    setSort('newest');
  };

  const plural = (n: number) => (n === 1 ? 'invoice' : 'invoices');
  const statusLabel = status === 'all' ? '' : STATUS_LABEL[status].toLowerCase();

  return (
    <section data-slot="page-header-03" className="bg-background py-16 sm:py-20">
      <div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 md:px-10">
        <ListHeader>
          <ListHeaderRow>
            <ListHeaderContent>
              <ListHeaderTitle className={ENTER}>Invoices</ListHeaderTitle>
              <ListHeaderCount style={stagger(1, 80)} className={ENTER}>
                {filtering
                  ? `${filtered.length} of ${invoices.length} ${plural(invoices.length)}`
                  : `${invoices.length} ${plural(invoices.length)}, ${moneyRounded.format(outstanding)} outstanding`}
              </ListHeaderCount>
            </ListHeaderContent>
            <ListHeaderActions style={stagger(2, 80)} className={ENTER}>
              <NewInvoiceDialog onCreate={create} />
            </ListHeaderActions>
          </ListHeaderRow>

          <ListHeaderToolbar aria-label="Filter invoices" style={stagger(3, 80)} className={ENTER}>
            <InputGroup className="md:max-w-xs">
              <InputGroupAddon align="inline-start">
                <Search aria-hidden />
              </InputGroupAddon>
              <InputGroupInput
                type="search"
                aria-label="Search invoices"
                placeholder="Search customer or number"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
              />
            </InputGroup>
            <div className="flex flex-wrap items-center gap-2 md:flex-1">
              <Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
                <SelectTrigger aria-label="Status" className="min-w-0 flex-1 md:w-40 md:flex-none">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent position="popper">
                  {STATUS_OPTIONS.map((option) => (
                    <SelectItem key={option.value} value={option.value}>
                      {option.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <Select value={sort} onValueChange={(value) => setSort(value as Sort)}>
                <SelectTrigger aria-label="Sort" className="min-w-0 flex-1 md:w-40 md:flex-none">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent position="popper">
                  {SORT_OPTIONS.map((option) => (
                    <SelectItem key={option.value} value={option.value}>
                      {option.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              {filtering ? (
                <Button type="button" variant="ghost" onClick={clearFilters} className={SWAP}>
                  <X aria-hidden />
                  Clear filters
                </Button>
              ) : null}
              <ToggleGroup
                type="single"
                variant="outline"
                value={view}
                onValueChange={(next) => {
                  // Keep one view selected when the active one is clicked again.
                  if (next) setView(next as View);
                }}
                aria-label="Layout"
                className="ms-auto"
              >
                <ToggleGroupItem value="list" aria-label="List view">
                  <List aria-hidden />
                </ToggleGroupItem>
                <ToggleGroupItem value="grid" aria-label="Grid view">
                  <LayoutGrid aria-hidden />
                </ToggleGroupItem>
              </ToggleGroup>
            </div>
          </ListHeaderToolbar>
        </ListHeader>

        {filtered.length === 0 ? (
          <div className={cn(SWAP, 'rounded-lg border border-dashed border-border')}>
            <Empty data-slot="page-header-03-empty">
              <EmptyHeader>
                <EmptyTitle>No invoices match</EmptyTitle>
                <EmptyDescription>
                  {needle
                    ? `Nothing matches "${query.trim()}"${statusLabel ? ` in ${statusLabel} invoices` : ''}.`
                    : `There are no ${statusLabel} invoices.`}{' '}
                  Try another search or clear the filters.
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent>
                <Button type="button" variant="outline" size="sm" onClick={clearFilters}>
                  Clear filters
                </Button>
              </EmptyContent>
            </Empty>
          </div>
        ) : view === 'list' ? (
          <div data-slot="page-header-03-list" key={`list-${sort}-${status}`} className="flex flex-col">
            <div
              aria-hidden
              className="hidden grid-cols-[6rem_1fr_6rem_6rem_7rem] gap-4 border-b border-border pb-2 text-xs text-muted-foreground uppercase sm:grid"
            >
              <span>Number</span>
              <span>Customer</span>
              <span>Due</span>
              <span>Status</span>
              <span className="text-end">Amount</span>
            </div>
            <ul className="flex flex-col divide-y divide-border">
              {filtered.map((invoice, index) => (
                <li
                  key={invoice.id}
                  data-slot="page-header-03-row"
                  style={stagger(Math.min(index, 8), 30)}
                  className={cn(
                    SWAP,
                    'grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1 py-3 text-sm sm:grid-cols-[6rem_1fr_6rem_6rem_7rem]',
                  )}
                >
                  <span className="order-3 text-muted-foreground tabular-nums sm:order-none sm:text-foreground">
                    {invoice.id}
                  </span>
                  <span className="order-1 truncate font-medium sm:order-none">{invoice.customer}</span>
                  <span className="hidden text-muted-foreground tabular-nums sm:block">{formatDate(invoice.due)}</span>
                  <span className="order-2 justify-self-end sm:order-none sm:justify-self-start">
                    <InvoiceStatus status={invoice.status} />
                  </span>
                  <span className="order-4 text-end tabular-nums sm:order-none">{money.format(invoice.amount)}</span>
                </li>
              ))}
            </ul>
          </div>
        ) : (
          <ul
            data-slot="page-header-03-grid"
            key={`grid-${sort}-${status}`}
            className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
          >
            {filtered.map((invoice, index) => (
              <li
                key={invoice.id}
                data-slot="page-header-03-tile"
                style={stagger(Math.min(index, 8), 30)}
                className={cn(SWAP, 'flex flex-col gap-4 rounded-lg border border-border bg-card p-4')}
              >
                <div className="flex items-start justify-between gap-3">
                  <div className="flex min-w-0 flex-col gap-0.5">
                    <span className="truncate text-sm font-medium">{invoice.customer}</span>
                    <span className="text-xs text-muted-foreground tabular-nums">{invoice.id}</span>
                  </div>
                  <InvoiceStatus status={invoice.status} />
                </div>
                <div className="flex items-baseline justify-between gap-3">
                  <span className="text-lg font-semibold tabular-nums">{money.format(invoice.amount)}</span>
                  <span className="text-xs text-muted-foreground tabular-nums">Due {formatDate(invoice.due)}</span>
                </div>
              </li>
            ))}
          </ul>
        )}
      </div>
    </section>
  );
};

interface NewInvoiceDialogProps {
  onCreate: (customer: string, amount: number) => void;
}

const NewInvoiceDialog = ({ onCreate }: NewInvoiceDialogProps) => {
  const [open, setOpen] = React.useState(false);
  const [customer, setCustomer] = React.useState('');
  const [amount, setAmount] = React.useState('');
  const [submitted, setSubmitted] = React.useState(false);

  const parsed = Number(amount);
  const customerError = customer.trim() === '' ? 'Enter a customer name.' : null;
  const amountError = !(parsed > 0) ? 'Enter an amount above zero.' : null;

  const reset = () => {
    setCustomer('');
    setAmount('');
    setSubmitted(false);
  };

  const submit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setSubmitted(true);
    if (customerError || amountError) return;
    onCreate(customer.trim(), Math.round(parsed * 100) / 100);
    setOpen(false);
    reset();
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (!next) reset();
      }}
    >
      <DialogTrigger asChild>
        <Button type="button">
          <Plus aria-hidden />
          New invoice
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-md">
        <form onSubmit={submit} noValidate className="flex flex-col gap-5">
          <DialogHeader>
            <DialogTitle>New invoice</DialogTitle>
            <DialogDescription>It is issued today and due in 30 days.</DialogDescription>
          </DialogHeader>
          <FieldGroup className="gap-4">
            <Field data-invalid={(submitted && Boolean(customerError)) || undefined}>
              <FieldLabel htmlFor="page-header-03-customer">Customer</FieldLabel>
              <Input
                id="page-header-03-customer"
                value={customer}
                autoComplete="off"
                placeholder="Harbor & Pine"
                aria-invalid={(submitted && Boolean(customerError)) || undefined}
                onChange={(event) => setCustomer(event.target.value)}
              />
              {submitted && customerError ? <FieldError>{customerError}</FieldError> : null}
            </Field>
            <Field data-invalid={(submitted && Boolean(amountError)) || undefined}>
              <FieldLabel htmlFor="page-header-03-amount">Amount in USD</FieldLabel>
              <Input
                id="page-header-03-amount"
                type="number"
                inputMode="decimal"
                min="0"
                step="0.01"
                placeholder="1200.00"
                value={amount}
                aria-invalid={(submitted && Boolean(amountError)) || undefined}
                onChange={(event) => setAmount(event.target.value)}
              />
              {submitted && amountError ? <FieldError>{amountError}</FieldError> : null}
            </Field>
          </FieldGroup>
          <DialogFooter>
            <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button type="submit">Create invoice</Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
};

export default PageHeader03;
```
