Patterns

Navigation and filter lists from flat item data — a `variant` enum picks link rows or filter tags; interactive state stays with the parent.

Install

npx shadcn@latest add @alix/list

Preview

Filter by focus

Usage

import { List, type ListProps } from "@/components/list";

const fromCMS: ListProps = await getList("capability-nav");

<List {...fromCMS} />

<List
  variant="tags"
  label="Capability filters"
  items={[
    { text: "Restructuring", value: "restructuring" },
    { text: "Transactions", value: "transactions" },
  ]}
  selected={selected}
  onSelect={toggleFilter}
/>

Examples

01

Compact navigation with a current page

The small size tightens the rows for secondary navigation; current marks the active page with aria-current.

<List
  variant="list"
  title="Capabilities"
  size="small"
  items={[
    { text: "Turnaround and restructuring", href: "/services/turnaround", current: true },
    { text: "Transactions and investor services", href: "/services/transactions" },
    { text: "Performance improvement", href: "/services/performance" },
  ]}
/>
02

Controlled filter tags

The tags variant emits each stable value; the parent owns selection state and can read it back for a count.

Filter by capability1 active
const [selected, setSelected] = useState(["restructuring"]);
const toggle = (value: string) =>
  setSelected((current) =>
    current.includes(value)
      ? current.filter((item) => item !== value)
      : [...current, value],
  );

return (
  <div className="w-full max-w-sm space-y-3">
    <div className="flex items-center justify-between gap-4">
      <span className="text-sm font-medium text-foreground">Filter by capability</span>
      <Badge variant="primary">{selected.length} active</Badge>
    </div>
    <List
      variant="tags"
      label="Filter by capability"
      items={[
        { text: "Restructuring", value: "restructuring" },
        { text: "Transactions", value: "transactions" },
        { text: "Performance", value: "performance" },
        { text: "Investigations", value: "investigations" },
      ]}
      selected={selected}
      onSelect={toggle}
    />
  </div>
);
03

Hover-linked to a preview panel

The onHover hook reports the focused row's index (or null on exit) so a sibling panel can preview it — the seam ServicesList uses.

const [active, setActive] = useState<number | null>(null);

return (
  <div className="grid w-full max-w-2xl gap-8 md:grid-cols-2">
    <List
      variant="list"
      title="Capabilities"
      items={capabilities.map(({ text, href }) => ({ text, href }))}
      onHover={setActive}
    />
    <div className="flex items-center border border-border p-6 text-sm text-muted-foreground">
      {active === null ? "Hover a capability to preview it." : capabilities[active].blurb}
    </div>
  </div>
);

When to use it

Use it for

  • Sidebar or footer navigation from a flat CMS link list, marking the current page.
  • Filter chips whose selected state is lifted to the common ancestor of the filters and the results.
  • Compact secondary navigation with size="small".

Reach for something else

  • A single link or a styled call-to-action — use SectionLink instead.
  • One filter chip on its own, or a bespoke tag layout — use ListItem directly instead.
  • A full industries and capabilities directory with hover previews — use ServicesList instead.

Props

PropTypeDefaultDescription
variant"list" | "tags"Required discriminant. `list` renders link rows; `tags` renders controlled filter chips.
items (list){ text: ReactNode; href: string; current?: boolean }[]Serializable link data. `current` marks the active page with `aria-current`.
items (tags){ text: ReactNode; value: string }[]Serializable filter options. `value` is the stable key emitted to the parent.
title / labelReactNode / string`title` is the visible heading and accessible name; use `label` when the list has no visible title.
size"default" | "small""default"List-row size. Applies only to `variant="list"`.
selected / onSelectstring[] / (value: string) => voidControlled tag state. This is parent-owned behavior, not CMS content.
onHover(index: number | null) => voidOptional parent hook for hover/focus previews, used by ServicesList.

See also