Skip to content

Single-feature spotlight

One capability given the room of a whole section, with a working demonstration.

Marketingintermediatefeaturesspotlightsingledemo

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 { ArrowRight, Sparkles } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Combobox } from '@/components/ui/combobox'
import { Field } from '@/components/ui/field'

/**
 * Single-feature spotlight
 *
 * One capability, given the room of a whole section. Used when a single
 * feature is the reason people choose the product — a grid of six would bury
 * it among five things nobody cares about.
 */
export default function SpotlightFeature() {
  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container>
        <div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
          <div>
            <Badge tone="accent" icon={<Sparkles className="size-3" />}>
              The one to look at
            </Badge>
            <h2 className="display-type mt-5 text-2xl leading-tight font-semibold text-ink-strong sm:text-3xl">
              A combobox that follows the pattern properly.
            </h2>
            <p className="mt-4 max-w-lg text-md text-ink-muted">
              Focus never leaves the input. Arrow keys move the active descendant, not DOM focus, so
              typing is never interrupted and the on-screen keyboard stays open on mobile.
            </p>
            <ul className="mt-6 flex flex-col gap-2 text-sm text-ink-muted">
              <li>· Filters across label, description and value</li>
              <li>· Home and End jump to the ends of the list</li>
              <li>· Writes a hidden input, so plain form submission works</li>
            </ul>
            <ButtonLink
              href="/components/combobox"
              className="mt-8"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              Read the component
            </ButtonLink>
          </div>

          <div className="rounded-xl border border-line bg-surface p-6 shadow-sm">
            <Field
              name="spotlight-repo"
              label="Repository"
              hint="Try typing “sections” — descriptions are searched too."
            >
              {(field) => (
                <Combobox
                  id={field.id}
                  name={field.name}
                  aria-describedby={field['aria-describedby']}
                  defaultValue="foundry-blocks"
                  options={[
                    {
                      value: 'foundry-core',
                      label: 'foundry/core',
                      description: 'Design tokens and primitives',
                    },
                    {
                      value: 'foundry-blocks',
                      label: 'foundry/blocks',
                      description: 'Sections and navigation',
                    },
                    {
                      value: 'foundry-docs',
                      label: 'foundry/docs',
                      description: 'Documentation site',
                    },
                    {
                      value: 'foundry-cli',
                      label: 'foundry/cli',
                      description: 'Component scaffolding',
                    },
                  ]}
                />
              )}
            </Field>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/features/spotlight.tsx

import { ArrowRight, Sparkles } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Combobox } from '@/components/ui/combobox'
import { Field } from '@/components/ui/field'

/**
 * Single-feature spotlight
 *
 * One capability, given the room of a whole section. Used when a single
 * feature is the reason people choose the product — a grid of six would bury
 * it among five things nobody cares about.
 */
export default function SpotlightFeature() {
  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container>
        <div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
          <div>
            <Badge tone="accent" icon={<Sparkles className="size-3" />}>
              The one to look at
            </Badge>
            <h2 className="display-type mt-5 text-2xl leading-tight font-semibold text-ink-strong sm:text-3xl">
              A combobox that follows the pattern properly.
            </h2>
            <p className="mt-4 max-w-lg text-md text-ink-muted">
              Focus never leaves the input. Arrow keys move the active descendant, not DOM focus, so
              typing is never interrupted and the on-screen keyboard stays open on mobile.
            </p>
            <ul className="mt-6 flex flex-col gap-2 text-sm text-ink-muted">
              <li>· Filters across label, description and value</li>
              <li>· Home and End jump to the ends of the list</li>
              <li>· Writes a hidden input, so plain form submission works</li>
            </ul>
            <ButtonLink
              href="/components/combobox"
              className="mt-8"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              Read the component
            </ButtonLink>
          </div>

          <div className="rounded-xl border border-line bg-surface p-6 shadow-sm">
            <Field
              name="spotlight-repo"
              label="Repository"
              hint="Try typing “sections” — descriptions are searched too."
            >
              {(field) => (
                <Combobox
                  id={field.id}
                  name={field.name}
                  aria-describedby={field['aria-describedby']}
                  defaultValue="foundry-blocks"
                  options={[
                    {
                      value: 'foundry-core',
                      label: 'foundry/core',
                      description: 'Design tokens and primitives',
                    },
                    {
                      value: 'foundry-blocks',
                      label: 'foundry/blocks',
                      description: 'Sections and navigation',
                    },
                    {
                      value: 'foundry-docs',
                      label: 'foundry/docs',
                      description: 'Documentation site',
                    },
                    {
                      value: 'foundry-cli',
                      label: 'foundry/cli',
                      description: 'Component scaffolding',
                    },
                  ]}
                />
              )}
            </Field>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/ui/combobox.tsx

'use client'

import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { ChevronDown, Check, Search } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
import { controlSurface } from './input'

/**
 * Combobox
 *
 * Implements the ARIA 1.2 combobox pattern with a filterable listbox popup:
 * the text input owns `role="combobox"`, `aria-expanded` and
 * `aria-activedescendant`, and the list is a real `role="listbox"` whose
 * options carry `aria-selected`.
 *
 * Focus never leaves the input, which is what makes arrow-key browsing feel
 * right and keeps the on-screen keyboard open on mobile.
 */
export interface ComboboxOption {
  value: string
  label: string
  description?: string
  disabled?: boolean
}

export interface ComboboxProps {
  options: ComboboxOption[]
  id?: string
  name?: string
  value?: string
  defaultValue?: string
  placeholder?: string
  emptyMessage?: string
  disabled?: boolean
  invalid?: boolean
  className?: string
  'aria-describedby'?: string
  onValueChange?: (value: string) => void
}

export function Combobox({
  options,
  id = 'combobox',
  name,
  value: controlledValue,
  defaultValue = '',
  placeholder = 'Select an option',
  emptyMessage = 'No matches found.',
  disabled = false,
  invalid = false,
  className,
  onValueChange,
  ...aria
}: ComboboxProps) {
  const [open, setOpen] = useState(false)
  const [query, setQuery] = useState('')
  const [activeIndex, setActiveIndex] = useState(0)
  const [uncontrolled, setUncontrolled] = useState(defaultValue)
  const value = controlledValue ?? uncontrolled

  const rootRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const listId = `${id}-listbox`

  const selected = options.find((option) => option.value === value)

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase()
    if (!q) return options
    return options.filter(
      (option) =>
        option.label.toLowerCase().includes(q) ||
        option.description?.toLowerCase().includes(q) ||
        option.value.toLowerCase().includes(q),
    )
  }, [options, query])

  useDismiss([rootRef], open, () => {
    setOpen(false)
    setQuery('')
  })

  const select = (option: ComboboxOption) => {
    if (option.disabled) return
    if (controlledValue === undefined) setUncontrolled(option.value)
    onValueChange?.(option.value)
    setOpen(false)
    setQuery('')
    inputRef.current?.focus()
  }

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault()
      if (!open) {
        setOpen(true)
        setActiveIndex(0)
        return
      }
      const direction = event.key === 'ArrowDown' ? 1 : -1
      setActiveIndex((current) => {
        if (filtered.length === 0) return 0
        return (current + direction + filtered.length) % filtered.length
      })
    } else if (event.key === 'Enter') {
      if (!open) return
      event.preventDefault()
      const option = filtered[activeIndex]
      if (option) select(option)
    } else if (event.key === 'Home' && open) {
      event.preventDefault()
      setActiveIndex(0)
    } else if (event.key === 'End' && open) {
      event.preventDefault()
      setActiveIndex(Math.max(0, filtered.length - 1))
    } else if (event.key === 'Tab') {
      setOpen(false)
    }
  }

  const activeOption = filtered[activeIndex]

  return (
    <div ref={rootRef} className={cn('relative', className)}>
      {name ? <input type="hidden" name={name} value={value} /> : null}
      <div className="relative flex items-center">
        <Search
          className="pointer-events-none absolute left-3 size-4 text-ink-subtle"
          aria-hidden="true"
        />
        <input
          ref={inputRef}
          id={id}
          role="combobox"
          type="text"
          autoComplete="off"
          disabled={disabled}
          aria-expanded={open}
          aria-controls={listId}
          aria-autocomplete="list"
          aria-activedescendant={
            open && activeOption ? `${id}-option-${activeOption.value}` : undefined
          }
          aria-invalid={invalid || undefined}
          aria-describedby={aria['aria-describedby']}
          placeholder={placeholder}
          value={open ? query : (selected?.label ?? '')}
          onChange={(event) => {
            setQuery(event.target.value)
            setActiveIndex(0)
            if (!open) setOpen(true)
          }}
          onFocus={() => setOpen(true)}
          onKeyDown={onKeyDown}
          className={cn(
            controlSurface,
            'h-control cursor-default pr-9 pl-9 text-sm',
            invalid && 'border-danger bg-danger-soft',
          )}
        />
        <button
          type="button"
          tabIndex={-1}
          aria-hidden="true"
          disabled={disabled}
          onClick={() => {
            setOpen((o) => !o)
            inputRef.current?.focus()
          }}
          className="absolute right-2 flex size-6 items-center justify-center rounded-sm text-ink-subtle"
        >
          <ChevronDown
            className={cn('size-4 transition-transform duration-150', open && 'rotate-180')}
          />
        </button>
      </div>

      {open ? (
        <ul
          id={listId}
          role="listbox"
          aria-label="Options"
          className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-md border border-line bg-surface-raised p-1 shadow-md thin-scrollbar"
        >
          {filtered.length === 0 ? (
            <li className="px-3 py-6 text-center text-sm text-ink-muted">{emptyMessage}</li>
          ) : (
            filtered.map((option, index) => (
              <li
                key={option.value}
                id={`${id}-option-${option.value}`}
                role="option"
                aria-selected={option.value === value}
                aria-disabled={option.disabled || undefined}
                onMouseEnter={() => setActiveIndex(index)}
                onPointerDown={(event) => {
                  event.preventDefault()
                  select(option)
                }}
                className={cn(
                  'flex cursor-pointer items-start gap-2 rounded-sm px-2.5 py-2 text-sm',
                  index === activeIndex && 'bg-surface-sunken',
                  option.disabled && 'cursor-not-allowed opacity-50',
                )}
              >
                <Check
                  className={cn(
                    'mt-0.5 size-4 shrink-0 text-accent',
                    option.value === value ? 'opacity-100' : 'opacity-0',
                  )}
                  aria-hidden="true"
                />
                <span className="min-w-0">
                  <span className="block truncate font-medium text-ink">{option.label}</span>
                  {option.description ? (
                    <span className="block truncate text-xs text-ink-muted">
                      {option.description}
                    </span>
                  ) : null}
                </span>
              </li>
            ))
          )}
        </ul>
      ) : null}
    </div>
  )
}

components/ui/field.tsx

import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Field
 *
 * The accessibility contract for every form control in Foundry lives here, in
 * one place, rather than being re-implemented per input.
 *
 * Ids are derived deterministically from `name` instead of `useId`, for two
 * reasons: the component stays renderable from a Server Component (no hooks),
 * and the markup is byte-identical between server and client, so a form works
 * before hydration — see `/docs/accessibility`.
 *
 * The render prop hands back the exact wiring a control needs, which makes it
 * impossible to forget `aria-describedby` on a field that has help text.
 */
export interface FieldRenderProps {
  id: string
  name: string
  'aria-describedby': string | undefined
  'aria-invalid': true | undefined
  'aria-required': true | undefined
  required: boolean
  invalid: boolean
}

export interface FieldProps {
  name: string
  label: ReactNode
  hint?: ReactNode
  /** Validation failure. Presence flips the field into its invalid state. */
  error?: string | null
  /** Confirmation message shown once a value has validated. */
  success?: string | null
  required?: boolean
  /** Marks the field "Optional" instead of marking required fields. */
  showOptional?: boolean
  /** Visually hides the label while keeping it available to screen readers. */
  hideLabel?: boolean
  className?: string
  /** Disambiguates ids when the same field name appears twice on one page. */
  idPrefix?: string
  children: (props: FieldRenderProps) => ReactNode
}

export function Field({
  name,
  label,
  hint,
  error,
  success,
  required = false,
  showOptional = false,
  hideLabel = false,
  className,
  idPrefix = 'field',
  children,
}: FieldProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const successId = success && !error ? `${id}-success` : undefined
  const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      <label
        htmlFor={id}
        className={cn(
          'flex items-baseline gap-1.5 text-sm font-medium text-ink',
          hideLabel && 'sr-only',
        )}
      >
        <span>{label}</span>
        {required && !showOptional ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
        {showOptional && !required ? (
          <span className="text-xs font-normal text-ink-subtle">Optional</span>
        ) : null}
      </label>

      {hint ? (
        <p id={hintId} className="text-xs leading-normal text-ink-muted">
          {hint}
        </p>
      ) : null}

      {children({
        id,
        name,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
        required,
        invalid: Boolean(error),
      })}

      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}

      {success && !error ? (
        <p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
          <CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{success}</span>
        </p>
      ) : null}
    </div>
  )
}

/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
  legend: ReactNode
  hint?: ReactNode
  error?: string | null
  name: string
  required?: boolean
  className?: string
  children: ReactNode
  idPrefix?: string
}

export function Fieldset({
  legend,
  hint,
  error,
  name,
  required = false,
  className,
  children,
  idPrefix = 'group',
}: FieldsetProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined

  return (
    <fieldset
      className={cn('flex min-w-0 flex-col gap-2', className)}
      aria-describedby={describedBy}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
    >
      <legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
        <span>{legend}</span>
        {required ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
      </legend>
      {hint ? (
        <p id={hintId} className="text-xs text-ink-muted">
          {hint}
        </p>
      ) : null}
      {children}
      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}
    </fieldset>
  )
}

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

Usage

Used when a single feature is the reason people choose the product — a grid of six would bury it among five things nobody cares about.

  • The demo must be interactive, or the section is just a large feature card.
  • Keep the bullets to three; they are supporting detail, not the argument.

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.

  • Copy and live demo
  • Bullet detail
  • Read-more action

Accessibility

Real component
The demonstration is the actual Combobox, with its full keyboard contract.
Labelled field
The demo field is wrapped in a Field, so the hint is announced.

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.