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 filesImport the component into your page or component file.
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.
Analytics
Panels keep their state
Inactive panels stay mounted by default, so charts, filters, and form inputs survive switching between sections. Read the guide.
Ecosystem
Motion-powered choreography
The active highlight glides between blades as a shared layout element, and panel content eases in after the expansion starts. Read the guide.
Show sourceexamples/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.
Shape the milestone
Collect scope, split it into vertical slices, and agree on the seams before any code is written.
Open the roadmap →Ship thin slices
Each slice lands end to end — component, tests, registry metadata, and docs move together.
See open branches →Release with confidence
Typecheck, tests, a11y automation, and registry validation gate every release candidate.
Read release notes →Show sourceexamples/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.
Vertical writing mode
True vertical writing mode reading top to bottom keeps glyphs upright, which suits short wayfinding labels. See the API.
Icon-only blade
Icon-only blades stay ultra compact and take an aria-label so assistive technology still announces a meaningful name. See the API.
Icon anchored to the end
Icon plus label with the icon anchored to the end of the blade axis via iconPosition. See the API.
Show sourceexamples/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
12Triage new messages without leaving the band — the blade tray keeps every folder one tap away.
Today
4Below the compact breakpoint the active panel moves above a horizontal blade tray sized for touch.
Archive
218Labels flatten to horizontal writing in compact mode, so short names and icons work best here.
Show sourceexamples/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.
| Prop | What it does | Default |
|---|---|---|
valuestring | undefined | Controlled active item value. undefined means no active item. Pair with onValueChange. | Not set |
defaultValuestring | Initial active item for uncontrolled usage. Omit to start fully collapsed. | Not set |
onValueChange(value: string | undefined) => void | Fires with the next value on activation, or undefined when the active item collapses. | Not set |
collapsibleboolean | Whether 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" |
bladeWidthnumber | Collapsed blade width in pixels, exposed as --horizontal-accordion-blade-width. | 72 |
heightnumber | Band height in pixels, exposed as --horizontal-accordion-height. Compact layout treats it as a minimum. | 420 |
compactBreakpointnumber | Container 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 } |
unmountInactivePanelsboolean | Unmounts inactive panel children after their exit animation instead of keeping them mounted and hidden. | false |
| Prop | What it does | Default |
|---|---|---|
Item valuestring | Required 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" |