Skip to content
Dethink Components

ComponentsHorizontalAccordion

HorizontalAccordion

Expand one panel at a time in a horizontal row.

On this page

Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.

View registry files

Import the component into your page or component file.

Usage
import { HorizontalAccordion } from "@dethink/components";

Try the examples, then open the code to use them in your app. Focus a blade and use Arrow, Home, and End keys to navigate; Enter or Space activates.

Basic

Uncontrolled with defaultValue. Clicking the active blade collapses it because collapsible defaults to true.

Start here

One expanded section at a time

Every section stays visible as a compact blade while the active section expands to fill the remaining width of the band. Read the guide.

Fixed heightAlways visibleSingle active
View changelog
Show sourceexamples/horizontal-accordion/basic.tsx
examples/horizontal-accordion/basic.tsx
"use client";

import type { ReactNode } from "react";
import { Button, HorizontalAccordion, Link } from "@dethink/components";

function CompassIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="m14.8 9.2-1.9 4.7-3.7 1.9 1.9-4.7 3.7-1.9Z" />
    </svg>
  );
}

function ChartIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="M4 19V5M4 19h16M8 15v-4m4 4V8m4 7v-6" />
    </svg>
  );
}

function LayersIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="m12 3 9 5-9 5-9-5 9-5Z" />
      <path d="m3 13 9 5 9-5" />
    </svg>
  );
}

const sections = [
  {
    value: "overview",
    label: "Overview",
    icon: <CompassIcon />,
    kicker: "Start here",
    title: "One expanded section at a time",
    body: "Every section stays visible as a compact blade while the active section expands to fill the remaining width of the band.",
    chips: ["Fixed height", "Always visible", "Single active"],
  },
  {
    value: "metrics",
    label: "Metrics",
    icon: <ChartIcon />,
    kicker: "Analytics",
    title: "Panels keep their state",
    body: "Inactive panels stay mounted by default, so charts, filters, and form inputs survive switching between sections.",
    chips: ["Mounted panels", "Controlled or not", "Keyboard ready"],
  },
  {
    value: "integrations",
    label: "Integrations",
    icon: <LayersIcon />,
    kicker: "Ecosystem",
    title: "Motion-powered choreography",
    body: "The active highlight glides between blades as a shared layout element, and panel content eases in after the expansion starts.",
    chips: ["Shared layout", "Reduced motion", "Tokenized"],
  },
];

function Chip({ children }: { children: ReactNode }) {
  return (
    <span className="border-border bg-muted text-muted-foreground rounded-full border px-2.5 py-0.5 text-xs font-medium">
      {children}
    </span>
  );
}

export function HorizontalAccordionBasic() {
  return (
    <HorizontalAccordion
      aria-label="Product sections"
      className="border-border rounded-lg border"
      compactBreakpoint={480}
      defaultValue="overview"
      height={320}
    >
      {sections.map((section) => (
        <HorizontalAccordion.Item key={section.value} value={section.value}>
          <HorizontalAccordion.Blade>
            <HorizontalAccordion.BladeIcon>
              {section.icon}
            </HorizontalAccordion.BladeIcon>
            <HorizontalAccordion.BladeLabel>
              {section.label}
            </HorizontalAccordion.BladeLabel>
          </HorizontalAccordion.Blade>
          <HorizontalAccordion.Panel>
            <div className="bg-background flex h-full flex-col justify-center gap-4 p-8">
              <div className="space-y-2">
                <p className="text-primary text-xs font-semibold tracking-[0.14em] uppercase">
                  {section.kicker}
                </p>
                <h3 className="text-foreground text-xl font-semibold">
                  {section.title}
                </h3>
                <p className="text-muted-foreground max-w-md text-sm leading-6">
                  {section.body}{" "}
                  <Link href="#basic" variant="muted" underline="always">
                    Read the guide
                  </Link>
                  .
                </p>
              </div>
              <div className="flex flex-wrap gap-2">
                {section.chips.map((chip) => (
                  <Chip key={chip}>{chip}</Chip>
                ))}
              </div>
              <div className="flex items-center gap-3">
                <Button size="sm" variant="soft">
                  Explore {section.label.toLowerCase()}
                </Button>
                <Link href="#basic" variant="muted">
                  View changelog
                </Link>
              </div>
            </div>
          </HorizontalAccordion.Panel>
        </HorizontalAccordion.Item>
      ))}
    </HorizontalAccordion>
  );
}

Controlled

Drive the active section from external state with value and onValueChange; pass undefined to collapse everything.

Active: build

Ship thin slices

Each slice lands end to end — component, tests, registry metadata, and docs move together.

See open branches
Show sourceexamples/horizontal-accordion/controlled.tsx
examples/horizontal-accordion/controlled.tsx
"use client";

import { useState } from "react";
import {
  Button,
  HorizontalAccordion,
  Link,
  type HorizontalAccordionValue,
} from "@dethink/components";

function ListIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01" />
    </svg>
  );
}

function HammerIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="m14 5 6 6-2.5 2.5-6-6L14 5ZM11.5 7.5 4 15l2.5 2.5 7.5-7.5" />
    </svg>
  );
}

function RocketIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="M12 15c4-3 6-7 6-11-4 0-8 2-11 6l-3 1 4 4 4 4 1-3Z" />
      <path d="M7 14c-1.5.5-2.5 3-2.5 5.5C7 19.5 9.5 18.5 10 17" />
    </svg>
  );
}

const phases = [
  {
    value: "plan",
    label: "Plan",
    icon: <ListIcon />,
    title: "Shape the milestone",
    body: "Collect scope, split it into vertical slices, and agree on the seams before any code is written.",
    cta: "Open the roadmap",
  },
  {
    value: "build",
    label: "Build",
    icon: <HammerIcon />,
    title: "Ship thin slices",
    body: "Each slice lands end to end — component, tests, registry metadata, and docs move together.",
    cta: "See open branches",
  },
  {
    value: "ship",
    label: "Ship",
    icon: <RocketIcon />,
    title: "Release with confidence",
    body: "Typecheck, tests, a11y automation, and registry validation gate every release candidate.",
    cta: "Read release notes",
  },
];

export function HorizontalAccordionControlled() {
  const [value, setValue] = useState<HorizontalAccordionValue>("build");

  return (
    <div className="space-y-4">
      <div className="flex flex-wrap items-center gap-2">
        {phases.map((phase) => (
          <Button
            key={phase.value}
            onClick={() => setValue(phase.value)}
            size="sm"
            variant={value === phase.value ? "soft" : "outline"}
          >
            {phase.label}
          </Button>
        ))}
        <Button onClick={() => setValue(undefined)} size="sm" variant="ghost">
          Collapse all
        </Button>
        <span className="text-muted-foreground text-sm">
          Active: {value ?? "none"}
        </span>
      </div>
      <HorizontalAccordion
        aria-label="Delivery phases"
        className="border-border rounded-lg border"
        compactBreakpoint={480}
        height={280}
        onValueChange={setValue}
        value={value}
      >
        {phases.map((phase) => (
          <HorizontalAccordion.Item key={phase.value} value={phase.value}>
            <HorizontalAccordion.Blade>
              <HorizontalAccordion.BladeIcon>
                {phase.icon}
              </HorizontalAccordion.BladeIcon>
              <HorizontalAccordion.BladeLabel>
                {phase.label}
              </HorizontalAccordion.BladeLabel>
            </HorizontalAccordion.Blade>
            <HorizontalAccordion.Panel>
              <div className="bg-background flex h-full items-center gap-5 p-8">
                <div className="bg-primary/10 text-primary flex size-12 shrink-0 items-center justify-center rounded-lg">
                  {phase.icon}
                </div>
                <div className="space-y-1.5">
                  <h3 className="text-foreground text-lg font-semibold">
                    {phase.title}
                  </h3>
                  <p className="text-muted-foreground max-w-md text-sm leading-6">
                    {phase.body}
                  </p>
                  <Link href="#controlled" underline="hover">
                    {phase.cta}
                  </Link>
                </div>
              </div>
            </HorizontalAccordion.Panel>
          </HorizontalAccordion.Item>
        ))}
      </HorizontalAccordion>
    </div>
  );
}

Blade content

Blades compose freely: rotated or vertical labels, icon-only blades with an aria-label, and icon-plus-label mixes with iconPosition.

Rotated label with icon

The default label orientation rotates text to read bottom to top, with a supporting icon at the start of the blade. See the API.

Show sourceexamples/horizontal-accordion/blade-content.tsx
examples/horizontal-accordion/blade-content.tsx
"use client";

import { Button, HorizontalAccordion, Link } from "@dethink/components";

function TypeIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="M5 7V5h14v2M12 5v14m-3 0h6" />
    </svg>
  );
}

function CompassIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="m14.8 9.2-1.9 4.7-3.7 1.9 1.9-4.7 3.7-1.9Z" />
    </svg>
  );
}

function SparklesIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="M12 4l1.6 4.4L18 10l-4.4 1.6L12 16l-1.6-4.4L6 10l4.4-1.6L12 4Z" />
      <path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" />
    </svg>
  );
}

function Panel({
  title,
  body,
  cta,
}: {
  title: string;
  body: string;
  cta: string;
}) {
  return (
    <div className="bg-background flex h-full flex-col justify-center gap-3 p-8">
      <h3 className="text-foreground text-lg font-semibold">{title}</h3>
      <p className="text-muted-foreground max-w-md text-sm leading-6">
        {body}{" "}
        <Link href="#blade-content" variant="muted" underline="always">
          See the API
        </Link>
        .
      </p>
      <div>
        <Button size="sm" variant="outline">
          {cta}
        </Button>
      </div>
    </div>
  );
}

export function HorizontalAccordionBladeContent() {
  return (
    <HorizontalAccordion
      aria-label="Blade content mixes"
      className="border-border rounded-lg border"
      compactBreakpoint={480}
      defaultValue="rotated"
      height={320}
    >
      <HorizontalAccordion.Item value="rotated">
        <HorizontalAccordion.Blade>
          <HorizontalAccordion.BladeIcon>
            <TypeIcon />
          </HorizontalAccordion.BladeIcon>
          <HorizontalAccordion.BladeLabel>
            Rotated
          </HorizontalAccordion.BladeLabel>
        </HorizontalAccordion.Blade>
        <HorizontalAccordion.Panel>
          <Panel
            body="The default label orientation rotates text to read bottom to top, with a supporting icon at the start of the blade."
            cta="Copy rotated recipe"
            title="Rotated label with icon"
          />
        </HorizontalAccordion.Panel>
      </HorizontalAccordion.Item>
      <HorizontalAccordion.Item value="vertical">
        <HorizontalAccordion.Blade>
          <HorizontalAccordion.BladeLabel
            direction="top-to-bottom"
            orientation="vertical"
          >
            Vertical
          </HorizontalAccordion.BladeLabel>
        </HorizontalAccordion.Blade>
        <HorizontalAccordion.Panel>
          <Panel
            body="True vertical writing mode reading top to bottom keeps glyphs upright, which suits short wayfinding labels."
            cta="Copy vertical recipe"
            title="Vertical writing mode"
          />
        </HorizontalAccordion.Panel>
      </HorizontalAccordion.Item>
      <HorizontalAccordion.Item value="icon-only">
        <HorizontalAccordion.Blade aria-label="Highlights">
          <HorizontalAccordion.BladeIcon>
            <SparklesIcon />
          </HorizontalAccordion.BladeIcon>
        </HorizontalAccordion.Blade>
        <HorizontalAccordion.Panel>
          <Panel
            body="Icon-only blades stay ultra compact and take an aria-label so assistive technology still announces a meaningful name."
            cta="Copy icon-only recipe"
            title="Icon-only blade"
          />
        </HorizontalAccordion.Panel>
      </HorizontalAccordion.Item>
      <HorizontalAccordion.Item value="mixed">
        <HorizontalAccordion.Blade iconPosition="end">
          <HorizontalAccordion.BladeIcon>
            <CompassIcon />
          </HorizontalAccordion.BladeIcon>
          <HorizontalAccordion.BladeLabel>
            Icon end
          </HorizontalAccordion.BladeLabel>
        </HorizontalAccordion.Blade>
        <HorizontalAccordion.Panel>
          <Panel
            body="Icon plus label with the icon anchored to the end of the blade axis via iconPosition."
            cta="Copy mixed recipe"
            title="Icon anchored to the end"
          />
        </HorizontalAccordion.Panel>
      </HorizontalAccordion.Item>
    </HorizontalAccordion>
  );
}

Compact layout

Below compactBreakpoint (measured on the container, not the viewport) the active panel moves above a horizontal blade tray and labels flatten.

Inbox

12

Triage new messages without leaving the band — the blade tray keeps every folder one tap away.

Show sourceexamples/horizontal-accordion/compact.tsx
examples/horizontal-accordion/compact.tsx
"use client";

import { Button, HorizontalAccordion } from "@dethink/components";

function InboxIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="M4 13h4l2 3h4l2-3h4M6 5h12l2 8v6H4v-6l2-8Z" />
    </svg>
  );
}

function SunIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <circle cx="12" cy="12" r="4" />
      <path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4" />
    </svg>
  );
}

function ArchiveIcon() {
  return (
    <svg
      aria-hidden="true"
      fill="none"
      height="18"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
      viewBox="0 0 24 24"
      width="18"
    >
      <path d="M4 5h16v4H4V5Zm1 4h14v10H5V9Zm5 4h4" />
    </svg>
  );
}

const folders = [
  {
    value: "inbox",
    label: "Inbox",
    icon: <InboxIcon />,
    count: 12,
    body: "Triage new messages without leaving the band — the blade tray keeps every folder one tap away.",
  },
  {
    value: "today",
    label: "Today",
    icon: <SunIcon />,
    count: 4,
    body: "Below the compact breakpoint the active panel moves above a horizontal blade tray sized for touch.",
  },
  {
    value: "archive",
    label: "Archive",
    icon: <ArchiveIcon />,
    count: 218,
    body: "Labels flatten to horizontal writing in compact mode, so short names and icons work best here.",
  },
];

export function HorizontalAccordionCompact() {
  return (
    <div className="mx-auto max-w-sm">
      <HorizontalAccordion
        aria-label="Compact layout demo"
        className="border-border rounded-lg border"
        defaultValue="inbox"
        height={300}
      >
        {folders.map((folder) => (
          <HorizontalAccordion.Item key={folder.value} value={folder.value}>
            <HorizontalAccordion.Blade>
              <HorizontalAccordion.BladeIcon>
                {folder.icon}
              </HorizontalAccordion.BladeIcon>
              <HorizontalAccordion.BladeLabel>
                {folder.label}
              </HorizontalAccordion.BladeLabel>
            </HorizontalAccordion.Blade>
            <HorizontalAccordion.Panel>
              <div className="bg-background flex h-full flex-col justify-center gap-3 p-6">
                <div className="flex items-center gap-2">
                  <h3 className="text-foreground text-lg font-semibold">
                    {folder.label}
                  </h3>
                  <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs font-semibold">
                    {folder.count}
                  </span>
                </div>
                <p className="text-muted-foreground text-sm leading-6">
                  {folder.body}
                </p>
                <div>
                  <Button size="sm" variant="soft">
                    Open {folder.label.toLowerCase()}
                  </Button>
                </div>
              </div>
            </HorizontalAccordion.Panel>
          </HorizontalAccordion.Item>
        ))}
      </HorizontalAccordion>
    </div>
  );
}

HorizontalAccordion renders a div and accepts all native div attributes. Blades render real buttons with aria-expanded and aria-controls; panels are labelled regions hidden when inactive. Every part accepts className and exposes data-slot, data-state, and data-layout attributes for styling.

HorizontalAccordion props
PropWhat it doesDefault
valuestring | undefinedControlled active item value. undefined means no active item. Pair with onValueChange.Not set
defaultValuestringInitial active item for uncontrolled usage. Omit to start fully collapsed.Not set
onValueChange(value: string | undefined) => voidFires with the next value on activation, or undefined when the active item collapses.Not set
collapsiblebooleanWhether activating the active blade collapses it. With false the accordion keeps one section open once activated.true
activationMode"manual" | "automatic"Manual activates blades with click, Enter, or Space. Automatic also activates on keyboard focus."manual"
bladeWidthnumberCollapsed blade width in pixels, exposed as --horizontal-accordion-blade-width.72
heightnumberBand height in pixels, exposed as --horizontal-accordion-height. Compact layout treats it as a minimum.420
compactBreakpointnumberContainer width in pixels at or below which the compact layout activates. Measured with a ResizeObserver, not the viewport.640
animation{ duration?: number; easing?: string; content?: boolean }Tunes the expansion and Motion choreography globally. content false disables panel content animation.{ duration: 260, easing: "cubic-bezier(0.2, 0, 0, 1)", content: true }
unmountInactivePanelsbooleanUnmounts inactive panel children after their exit animation instead of keeping them mounted and hidden.false
Part props
PropWhat it doesDefault
Item valuestringRequired stable identity for each item. Duplicate values throw in development.Not set
Blade iconPosition"start" | "end" | "top" | "bottom"Position of BladeIcon along the blade axis. top and bottom are aliases for start and end."start"
BladeLabel orientation"rotated" | "vertical"Rotated turns horizontal text; vertical uses a true vertical writing mode."rotated"
BladeLabel direction"bottom-to-top" | "top-to-bottom"Reading direction of the blade label text."bottom-to-top"