Single-feature spotlight
One capability given the room of a whole section, with a working demonstration.
Live preview
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.
Related
All sectionsCombobox
A filterable single-select implementing the ARIA 1.2 combobox pattern, with active-descendant browsing that never moves focus out of the input.
advancedFeatured5 variantsInteractive theme preview
A feature section that demonstrates the feature by re-theming itself.
advancedFeatured3 variantsAlternating feature rows
Copy and visual swapping sides down the page, with DOM order held constant.
intermediateFeatured3 variants