Filtered to nothing
The data exists — the filters exclude it, and the state says so.
Live preview
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.
Related
All sectionsNo search results
The failed-query state, with the query echoed and the active filters shown.
starterFeatured3 variantsTable
A semantic table with two responsive strategies, a focusable scroll region and a built-in empty state.
intermediateFeatured5 variantsDashboard data table
A flush panel with a table that becomes labelled cards below md.
intermediateFeatured3 variants