Skip to content

Filtered to nothing

The data exists — the filters exclude it, and the state says so.

Statesstarteremptyfiltersrecoverytable

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { FilterX } from 'lucide-react'
import { EmptyState } from '@/components/ui/empty-state'
import { Panel } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'

/**
 * Filtered-to-nothing state
 *
 * Distinct from "no results" because the data exists — the filters simply
 * exclude it. Telling the user how many records are hidden, and offering to
 * remove one filter rather than all of them, is what turns a dead end into a
 * one-click recovery.
 */
const activeFilters = [
  { label: 'Status: Failed', removable: true },
  { label: 'Environment: Preview', removable: true },
  { label: 'Last 24 hours', removable: true },
]

export default function FilteredOutEmptyState() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Deployments"
        description="1,284 records, 0 matching the current filters"
        headingLevel="h2"
        action={
          <Button variant="outline" size="sm">
            Reset filters
          </Button>
        }
        flush
      >
        <div className="flex flex-wrap items-center gap-2 border-b border-line-subtle bg-surface-sunken px-4 py-3">
          <span className="text-xs text-ink-muted">Active filters:</span>
          {activeFilters.map((filter) => (
            <Badge key={filter.label} tone="accent">
              {filter.label}
            </Badge>
          ))}
        </div>

        <div className="p-4">
          <EmptyState
            appearance="bare"
            icon={<FilterX className="size-5" />}
            title="No deployments match all three filters"
            description="1,284 deployments exist. Removing the time filter would show 42 of them."
            action={<Button size="sm">Remove “Last 24 hours”</Button>}
            secondaryAction={
              <Button size="sm" variant="ghost">
                Clear all filters
              </Button>
            }
          />
        </div>
      </Panel>
    </section>
  )
}

components/blocks/sections/empty/filtered-out.tsx

import { FilterX } from 'lucide-react'
import { EmptyState } from '@/components/ui/empty-state'
import { Panel } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'

/**
 * Filtered-to-nothing state
 *
 * Distinct from "no results" because the data exists — the filters simply
 * exclude it. Telling the user how many records are hidden, and offering to
 * remove one filter rather than all of them, is what turns a dead end into a
 * one-click recovery.
 */
const activeFilters = [
  { label: 'Status: Failed', removable: true },
  { label: 'Environment: Preview', removable: true },
  { label: 'Last 24 hours', removable: true },
]

export default function FilteredOutEmptyState() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Deployments"
        description="1,284 records, 0 matching the current filters"
        headingLevel="h2"
        action={
          <Button variant="outline" size="sm">
            Reset filters
          </Button>
        }
        flush
      >
        <div className="flex flex-wrap items-center gap-2 border-b border-line-subtle bg-surface-sunken px-4 py-3">
          <span className="text-xs text-ink-muted">Active filters:</span>
          {activeFilters.map((filter) => (
            <Badge key={filter.label} tone="accent">
              {filter.label}
            </Badge>
          ))}
        </div>

        <div className="p-4">
          <EmptyState
            appearance="bare"
            icon={<FilterX className="size-5" />}
            title="No deployments match all three filters"
            description="1,284 deployments exist. Removing the time filter would show 42 of them."
            action={<Button size="sm">Remove “Last 24 hours”</Button>}
            secondaryAction={
              <Button size="sm" variant="ghost">
                Clear all filters
              </Button>
            }
          />
        </div>
      </Panel>
    </section>
  )
}

components/ui/empty-state.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * EmptyState
 *
 * An empty state is a piece of product writing more than a piece of UI, so the
 * component enforces the three parts that make one useful: what is missing,
 * why it might be missing, and the single most likely next action.
 */
export interface EmptyStateProps {
  icon?: ReactNode
  title: string
  description?: string
  action?: ReactNode
  secondaryAction?: ReactNode
  /** `panel` draws a dashed enclosure; `bare` sits inside an existing panel. */
  appearance?: 'panel' | 'bare'
  size?: 'sm' | 'md' | 'lg'
  className?: string
}

export function EmptyState({
  icon,
  title,
  description,
  action,
  secondaryAction,
  appearance = 'panel',
  size = 'md',
  className,
}: EmptyStateProps) {
  const padding = { sm: 'py-8', md: 'py-12', lg: 'py-20' }[size]

  return (
    <div
      className={cn(
        'flex flex-col items-center px-6 text-center',
        padding,
        appearance === 'panel' &&
          'rounded-lg border border-dashed border-line-strong bg-surface-sunken/60',
        className,
      )}
    >
      {icon ? (
        <div className="mb-4 flex size-11 items-center justify-center rounded-full border border-line bg-surface text-ink-subtle">
          {icon}
        </div>
      ) : null}
      <p className="text-md font-semibold text-ink-strong text-balance">{title}</p>
      {description ? (
        <p className="mt-1.5 max-w-sm text-sm text-ink-muted text-pretty">{description}</p>
      ) : null}
      {(action || secondaryAction) && (
        <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
          {action}
          {secondaryAction}
        </div>
      )}
    </div>
  )
}

components/ui/card.tsx

import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'

/**
 * Card & Panel
 *
 * Two containment primitives with deliberately different jobs:
 *
 *   Card  — a discrete, often interactive record in a collection.
 *   Panel — a titled region of a page, with an optional header action row.
 *
 * Keeping them separate is what stops the library from degenerating into
 * "everything is a rounded box with a shadow".
 */

export interface CardProps extends HTMLAttributes<HTMLDivElement> {
  as?: ElementType
  /** Forwarded when `as` renders a link. */
  href?: string
  /** `outline` is the default; `raised` adds elevation; `sunken` insets. */
  tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
  /** Adds hover affordance. Only use when the whole card is a link/button. */
  interactive?: boolean
  padding?: 'none' | 'sm' | 'md' | 'lg'
}

const cardTones = {
  outline: 'bg-surface border border-line',
  raised: 'bg-surface-raised border border-line shadow-sm',
  sunken: 'bg-surface-sunken border border-line-subtle',
  ghost: 'bg-transparent border border-transparent',
  accent: 'bg-accent-soft border border-accent-line',
} as const

const cardPadding = {
  none: 'p-0',
  sm: 'p-3',
  md: 'p-card',
  lg: 'p-6 sm:p-8',
} as const

export function Card({
  as: Tag = 'div',
  tone = 'outline',
  interactive = false,
  padding = 'md',
  className,
  children,
  ...props
}: CardProps) {
  return (
    <Tag
      className={cn(
        'rounded-lg',
        cardTones[tone],
        cardPadding[padding],
        interactive &&
          'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
        className,
      )}
      {...props}
    >
      {children}
    </Tag>
  )
}

export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div className={cn('flex items-start justify-between gap-4', className)} {...props}>
      {children}
    </div>
  )
}

export function CardTitle({
  as: Tag = 'h3',
  className,
  children,
  ...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
  return (
    <Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
      {children}
    </Tag>
  )
}

export function CardDescription({
  className,
  children,
  ...props
}: HTMLAttributes<HTMLParagraphElement>) {
  return (
    <p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
      {children}
    </p>
  )
}

export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn(
        'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
        className,
      )}
      {...props}
    >
      {children}
    </div>
  )
}

export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
  title: ReactNode
  description?: ReactNode
  /** Rendered on the right of the panel header. */
  action?: ReactNode
  /** Removes body padding — for tables and lists that manage their own. */
  flush?: boolean
  as?: ElementType
  headingLevel?: 'h2' | 'h3' | 'h4'
}

export function Panel({
  title,
  description,
  action,
  flush = false,
  as: Tag = 'section',
  headingLevel: Heading = 'h3',
  className,
  children,
  ...props
}: PanelProps) {
  return (
    <Tag
      className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
      {...props}
    >
      <div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
        <div className="min-w-0">
          <Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
          {description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
        </div>
        {action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
      </div>
      <div className={cn(flush ? '' : 'p-card')}>{children}</div>
    </Tag>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Telling the user how many records are hidden, and offering to remove one filter rather than all of them, is what turns a dead end into a one-click recovery.

  • Name which filter to remove and what it would reveal.
  • Keep the panel header showing the true record count, not zero.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Active filter bar
  • Record count
  • Remove-one-filter action

Accessibility

Bare empty state
The empty state sits inside an existing panel without a box-in-a-box.
Specific action
The primary action names the filter it removes.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.