Skip to content

No search results

The failed-query state, with the query echoed and the active filters shown.

StatesstarterFeaturedemptysearchno-resultsfilters

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 { SearchX } from 'lucide-react'
import Link from 'next/link'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Badge } from '@/components/ui/badge'

/**
 * No search results
 *
 * The failed-query state, which is a different problem from "nothing here yet".
 * Three things make it useful: the query echoed back so a typo is visible, the
 * active filters shown so an over-narrowed search is obvious, and suggestions
 * that are real links rather than advice.
 */
const suggestions = ['dialog', 'combobox', 'checkout', 'pricing']

export default function NoResultsEmptyState() {
  const query = 'observabilty'

  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <div className="mb-6 flex flex-wrap items-center gap-2">
          <span className="text-sm text-ink-muted">Filters:</span>
          <Badge tone="accent">Category: Overlay</Badge>
          <Badge tone="accent">Difficulty: Advanced</Badge>
          <Link href="/components" className="text-sm text-accent underline underline-offset-4">
            Clear all
          </Link>
        </div>

        <EmptyState
          icon={<SearchX className="size-5" />}
          title={`No results for “${query}”`}
          description="Two filters are active, which may be narrowing this too far. Check the spelling, or clear the filters and try again."
          action={<ButtonLink href="/components">Clear filters</ButtonLink>}
          secondaryAction={
            <ButtonLink href="/search" variant="outline">
              Search everything
            </ButtonLink>
          }
        />

        <div className="mt-8 text-center">
          <p className="label-caps text-ink-subtle">Try instead</p>
          <ul className="mt-3 flex flex-wrap items-center justify-center gap-2">
            {suggestions.map((suggestion) => (
              <li key={suggestion}>
                <Link
                  href={`/search?q=${suggestion}`}
                  className="inline-flex rounded-full border border-line bg-surface px-3 py-1.5 text-xs text-ink-muted transition-colors hover:border-accent hover:text-accent"
                >
                  {suggestion}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/empty/no-results.tsx

import { SearchX } from 'lucide-react'
import Link from 'next/link'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Badge } from '@/components/ui/badge'

/**
 * No search results
 *
 * The failed-query state, which is a different problem from "nothing here yet".
 * Three things make it useful: the query echoed back so a typo is visible, the
 * active filters shown so an over-narrowed search is obvious, and suggestions
 * that are real links rather than advice.
 */
const suggestions = ['dialog', 'combobox', 'checkout', 'pricing']

export default function NoResultsEmptyState() {
  const query = 'observabilty'

  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <div className="mb-6 flex flex-wrap items-center gap-2">
          <span className="text-sm text-ink-muted">Filters:</span>
          <Badge tone="accent">Category: Overlay</Badge>
          <Badge tone="accent">Difficulty: Advanced</Badge>
          <Link href="/components" className="text-sm text-accent underline underline-offset-4">
            Clear all
          </Link>
        </div>

        <EmptyState
          icon={<SearchX className="size-5" />}
          title={`No results for “${query}”`}
          description="Two filters are active, which may be narrowing this too far. Check the spelling, or clear the filters and try again."
          action={<ButtonLink href="/components">Clear filters</ButtonLink>}
          secondaryAction={
            <ButtonLink href="/search" variant="outline">
              Search everything
            </ButtonLink>
          }
        />

        <div className="mt-8 text-center">
          <p className="label-caps text-ink-subtle">Try instead</p>
          <ul className="mt-3 flex flex-wrap items-center justify-center gap-2">
            {suggestions.map((suggestion) => (
              <li key={suggestion}>
                <Link
                  href={`/search?q=${suggestion}`}
                  className="inline-flex rounded-full border border-line bg-surface px-3 py-1.5 text-xs text-ink-muted transition-colors hover:border-accent hover:text-accent"
                >
                  {suggestion}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      </Container>
    </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/badge.tsx

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

/**
 * Badge
 *
 * A compact, non-interactive label. Tones map to the status token trios, and
 * because a badge is often the only signal in a dense table, the `dot` option
 * exists to add a second, colour-independent cue alongside the text.
 */
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
  variants: {
    tone: {
      neutral: 'bg-surface-sunken text-ink-muted border-line',
      accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
      success: 'bg-success-soft text-success border-success-line',
      warning: 'bg-warning-soft text-warning border-warning-line',
      danger: 'bg-danger-soft text-danger border-danger-line',
      info: 'bg-info-soft text-info border-info-line',
      inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
    },
    appearance: {
      soft: 'border',
      outline: 'border bg-transparent',
      solid: 'border border-transparent',
    },
    size: {
      sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
      md: 'h-5.5 rounded-sm px-2 text-xs',
    },
  },
  defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
  compound: [
    { appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
    { appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
    { appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
    { appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
    { appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
    { appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
    { appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
  ],
})

export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  tone?: BadgeTone
  appearance?: 'soft' | 'outline' | 'solid'
  size?: 'sm' | 'md'
  /** Adds a leading dot so the badge does not rely on hue alone. */
  dot?: boolean
  icon?: ReactNode
}

export function Badge({
  tone = 'neutral',
  appearance = 'soft',
  size = 'md',
  dot = false,
  icon,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span className={badgeVariants({ tone, appearance, size, className })} {...props}>
      {dot ? (
        <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
      ) : null}
      {icon}
      {children}
    </span>
  )
}

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

Usage

A different problem from 'nothing here yet'. Echoing the query makes a typo visible, showing the filters makes an over-narrowed search obvious, and the suggestions are links rather than advice.

  • Always echo the query. 'No results' without it is unactionable.
  • Show the filters that are narrowing the result, with a way to clear them.

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.

  • Query echo
  • Active filter chips
  • Real suggestion links

Accessibility

Query in the heading
The failed term is part of the announced title.
Real links
Suggestions navigate rather than only re-running a client-side filter.

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.