# Empty State 02

> A customer list with search and plan and status filters. When nothing matches, it repeats the query, says how many matches the filters hide, suggests close matches and offers to clear filters.

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

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

## Install

```bash
npx shadcn@latest add https://hirael.com/r/empty-state-02.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`, `empty`, `input-group`, `toggle-group`

## Files

### `registry/hirael/bases/radix/blocks/empty-state-02/empty-state-02.tsx`

```tsx
// Empty State 02 from Hirael <https://hirael.com/blocks/empty-states/empty-state-02>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { SearchX, 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 {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/registry/hirael/bases/radix/ui/empty';
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/registry/hirael/bases/radix/ui/input-group';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

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 PLANS = ['Free', 'Pro', 'Enterprise'] as const;
const STATUSES = ['Active', 'Past due', 'Canceled'] as const;

type Plan = (typeof PLANS)[number];
type Status = (typeof STATUSES)[number];

interface Customer {
  name: string;
  email: string;
  plan: Plan;
  status: Status;
  /** Monthly recurring revenue in USD. */
  mrr: number;
}

const CUSTOMERS: readonly Customer[] = [
  { name: 'Hirael Design', email: 'design@hirael.com', plan: 'Enterprise', status: 'Active', mrr: 4200 },
  { name: 'Hirael Desktop', email: 'desktop@hirael.com', plan: 'Pro', status: 'Active', mrr: 290 },
  { name: 'Hirael Cloud', email: 'cloud@hirael.com', plan: 'Enterprise', status: 'Past due', mrr: 3800 },
  { name: 'Hirael Labs', email: 'labs@hirael.com', plan: 'Pro', status: 'Active', mrr: 190 },
  { name: 'Hirael Docs', email: 'docs@hirael.com', plan: 'Free', status: 'Active', mrr: 0 },
  { name: 'Hirael Commerce', email: 'commerce@hirael.com', plan: 'Enterprise', status: 'Active', mrr: 5600 },
  { name: 'Hirael Studio', email: 'studio@hirael.com', plan: 'Pro', status: 'Past due', mrr: 290 },
  { name: 'Hirael Academy', email: 'academy@hirael.com', plan: 'Free', status: 'Canceled', mrr: 0 },
  { name: 'Hirael Store', email: 'store@hirael.com', plan: 'Pro', status: 'Active', mrr: 490 },
  { name: 'Hirael Mobile', email: 'mobile@hirael.com', plan: 'Pro', status: 'Canceled', mrr: 0 },
  { name: 'Hirael Analytics', email: 'analytics@hirael.com', plan: 'Enterprise', status: 'Active', mrr: 7200 },
  { name: 'Hirael Blog', email: 'blog@hirael.com', plan: 'Free', status: 'Active', mrr: 0 },
];

const STATUS_DOT: Record<Status, string> = {
  Active: 'bg-success',
  'Past due': 'bg-warning',
  Canceled: 'bg-muted-foreground/50',
};

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

const matchesQuery = (customer: Customer, query: string) => {
  const q = query.trim().toLowerCase();

  return !q || customer.name.toLowerCase().includes(q) || customer.email.toLowerCase().includes(q);
};

const matchesFilters = (customer: Customer, plans: readonly string[], statuses: readonly string[]) =>
  (plans.length === 0 || plans.includes(customer.plan)) &&
  (statuses.length === 0 || statuses.includes(customer.status));

const levenshtein = (a: string, b: string) => {
  let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
  for (let i = 1; i <= a.length; i++) {
    const current = [i];
    for (let j = 1; j <= b.length; j++) {
      current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
    }
    previous = current;
  }

  return previous[b.length];
};

// Scores each word of a name, so "desing" still finds "Hirael Design"; a shared 3-letter prefix is a weaker hit.
const suggest = (query: string, customers: readonly Customer[], limit = 2) => {
  const q = query.trim().toLowerCase();
  if (q.length < 3) return [];
  const threshold = Math.max(1, Math.floor(q.length / 3));

  return customers
    .map((customer) => {
      const words = customer.name.toLowerCase().split(/[^a-z0-9]+/);
      const distance = Math.min(...words.map((word) => levenshtein(q, word)));
      const prefix = words.some((word) => word.startsWith(q.slice(0, 3)));
      const score = distance <= threshold ? distance : prefix ? threshold + 1 : Infinity;

      return { customer, score };
    })
    .filter((entry) => entry.score !== Infinity)
    .sort((a, b) => a.score - b.score)
    .slice(0, limit)
    .map((entry) => entry.customer);
};

const EmptyState02 = () => {
  const [query, setQuery] = React.useState('desing');
  const [plans, setPlans] = React.useState<string[]>(['Pro']);
  const [statuses, setStatuses] = React.useState<string[]>(['Active']);
  const searchRef = React.useRef<HTMLInputElement>(null);

  const trimmed = query.trim();
  const results = CUSTOMERS.filter((c) => matchesQuery(c, trimmed) && matchesFilters(c, plans, statuses));
  const hidden = trimmed ? CUSTOMERS.filter((c) => matchesQuery(c, trimmed)).length : 0;
  const suggestions = hidden === 0 ? suggest(trimmed, CUSTOMERS) : [];
  const hasFilters = plans.length + statuses.length > 0;

  const clearFilters = () => {
    setPlans([]);
    setStatuses([]);
  };

  const applySuggestion = (customer: Customer) => {
    setQuery(customer.name);
    if (!matchesFilters(customer, plans, statuses)) clearFilters();
  };

  return (
    <section data-slot="empty-state-02" className="bg-background">
      <div className="mx-auto w-full max-w-3xl px-6 py-16 sm:py-24">
        <div
          data-slot="empty-state-02-panel"
          className={cn(ENTER, 'overflow-hidden rounded-lg border border-border bg-card text-card-foreground')}
        >
          <header data-slot="empty-state-02-header" className="flex flex-col gap-3 border-b border-border px-5 py-4">
            <div className="flex items-baseline justify-between gap-4">
              <h2 className="text-sm font-medium text-foreground">Customers</h2>
              <p aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
                Showing {results.length} of {CUSTOMERS.length}
              </p>
            </div>

            <InputGroup>
              <InputGroupAddon>
                <Search aria-hidden />
              </InputGroupAddon>
              <InputGroupInput
                ref={searchRef}
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="Search by name or email"
                aria-label="Search customers"
              />
              {query ? (
                <InputGroupAddon align="inline-end">
                  <InputGroupButton
                    size="icon-xs"
                    aria-label="Clear search"
                    onClick={() => {
                      setQuery('');
                      searchRef.current?.focus();
                    }}
                  >
                    <X />
                  </InputGroupButton>
                </InputGroupAddon>
              ) : null}
            </InputGroup>

            <div data-slot="empty-state-02-filters" className="flex flex-wrap items-center gap-x-5 gap-y-2">
              <div className="flex items-center gap-2">
                <span className="text-xs text-muted-foreground uppercase">Plan</span>
                <ToggleGroup
                  type="multiple"
                  variant="outline"
                  size="sm"
                  value={plans}
                  onValueChange={setPlans}
                  aria-label="Filter by plan"
                >
                  {PLANS.map((plan) => (
                    <ToggleGroupItem key={plan} value={plan}>
                      {plan}
                    </ToggleGroupItem>
                  ))}
                </ToggleGroup>
              </div>
              <div className="flex items-center gap-2">
                <span className="text-xs text-muted-foreground uppercase">Status</span>
                <ToggleGroup
                  type="multiple"
                  variant="outline"
                  size="sm"
                  value={statuses}
                  onValueChange={setStatuses}
                  aria-label="Filter by status"
                >
                  {STATUSES.map((status) => (
                    <ToggleGroupItem key={status} value={status}>
                      {status}
                    </ToggleGroupItem>
                  ))}
                </ToggleGroup>
              </div>
            </div>
          </header>

          {results.length === 0 ? (
            <Empty className={SWAP}>
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <SearchX />
                </EmptyMedia>
                <EmptyTitle>{trimmed ? `No results for “${trimmed}”` : 'No customers match these filters'}</EmptyTitle>
                <EmptyDescription>
                  {hidden > 0
                    ? `${hidden} ${hidden === 1 ? 'customer matches' : 'customers match'} your search but ${hidden === 1 ? 'is' : 'are'} hidden by the plan and status filters.`
                    : trimmed
                      ? 'Check the spelling, or search by email address instead.'
                      : 'Try removing a filter to widen the list.'}
                </EmptyDescription>
              </EmptyHeader>

              <EmptyContent>
                {suggestions.length > 0 ? (
                  <div
                    data-slot="empty-state-02-suggestions"
                    className="flex flex-wrap items-center justify-center gap-2"
                  >
                    <span className="text-sm text-muted-foreground">Did you mean</span>
                    {suggestions.map((customer) => (
                      <Button
                        key={customer.name}
                        type="button"
                        variant="outline"
                        size="sm"
                        onClick={() => applySuggestion(customer)}
                      >
                        {customer.name}
                      </Button>
                    ))}
                  </div>
                ) : null}

                <div className="flex flex-wrap justify-center gap-2">
                  {hasFilters ? (
                    <Button type="button" variant={hidden > 0 ? 'default' : 'outline'} size="sm" onClick={clearFilters}>
                      Clear filters
                    </Button>
                  ) : null}
                  {trimmed ? (
                    <Button type="button" variant="ghost" size="sm" onClick={() => setQuery('')}>
                      Clear search
                    </Button>
                  ) : null}
                </div>
              </EmptyContent>
            </Empty>
          ) : (
            <ul data-slot="empty-state-02-list" className="divide-y divide-border">
              {results.map((customer) => (
                <li
                  key={customer.name}
                  data-slot="empty-state-02-row"
                  className={cn(SWAP, 'flex items-center gap-4 px-5 py-3')}
                >
                  <span className="flex min-w-0 flex-1 flex-col">
                    <span className="truncate text-sm font-medium text-foreground">{customer.name}</span>
                    <span className="truncate text-xs text-muted-foreground">{customer.email}</span>
                  </span>
                  <Badge variant="secondary" className="hidden sm:inline-flex">
                    {customer.plan}
                  </Badge>
                  <span className="hidden w-20 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
                    <span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', STATUS_DOT[customer.status])} />
                    {customer.status}
                  </span>
                  <span className="w-16 text-end text-sm text-foreground tabular-nums">
                    {currency.format(customer.mrr)}
                  </span>
                </li>
              ))}
            </ul>
          )}
        </div>
      </div>
    </section>
  );
};

export default EmptyState02;
```
