Accordion
Stack collapsible sections to fold long content into a scannable list. Items are divided by a single hairline rule, each a heading/trigger over a panel whose height animates open and closed, with a muted chevron that rotates a half-turn as it expands.
Basic
Each item is a trigger over a panel; the trigger renders inside a heading for a correct outline. By default one section is open at a time — opening another closes the last. The trigger row highlights neutrally on hover; the accent is held for the focus ring alone.
"use client";
import { Accordion } from "@stridge/noctis";
export default function AccordionBasic() {
return (
<Accordion.Root defaultValue={["shipping"]} className="w-full max-w-md">
<Accordion.Item value="shipping">
<Accordion.Trigger>When will my order ship?</Accordion.Trigger>
<Accordion.Panel>Orders placed before 2pm ship the same business day.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="returns">
<Accordion.Trigger>What is the return window?</Accordion.Trigger>
<Accordion.Panel>Returns are free within thirty days of delivery.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="warranty">
<Accordion.Trigger>Is there a warranty?</Accordion.Trigger>
<Accordion.Panel>Every product carries a one-year limited warranty.</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
);
}
Multiple open
Pass multiple to let several sections stay open together — for settings groups or checklists where the reader compares sections side by side rather than stepping through them one at a time.
"use client";
import { Accordion } from "@stridge/noctis";
export default function AccordionMultiple() {
return (
<Accordion.Root multiple defaultValue={["overview", "billing"]} className="w-full max-w-md">
<Accordion.Item value="overview">
<Accordion.Trigger>Overview</Accordion.Trigger>
<Accordion.Panel>A summary of the workspace and its members.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="billing">
<Accordion.Trigger>Billing</Accordion.Trigger>
<Accordion.Panel>Manage the plan, invoices, and payment method.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="security">
<Accordion.Trigger>Security</Accordion.Trigger>
<Accordion.Panel>Two-factor authentication and active sessions.</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
);
}
Sizes
size sets the trigger rhythm: md (default) for FAQ and marketing, sm for dense settings panes. It scales the row padding, label type, chevron, and content together.
"use client";
import { Accordion } from "@stridge/noctis";
const SECTIONS = [
{ value: "appearance", title: "Appearance", body: "Theme, accent colour, and density for the whole workspace." },
{ value: "shortcuts", title: "Keyboard shortcuts", body: "Review and remap the shortcuts for every command." },
];
function Stack({ size }: { size: "sm" | "md" }) {
return (
<Accordion.Root size={size} defaultValue={["appearance"]} className="w-full">
{SECTIONS.map(({ value, title, body }) => (
<Accordion.Item key={value} value={value}>
<Accordion.Trigger>{title}</Accordion.Trigger>
<Accordion.Panel>{body}</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
);
}
export default function AccordionSizes() {
return (
<div className="flex w-full max-w-md flex-col gap-8">
<Stack size="sm" />
<Stack size="md" />
</div>
);
}
Borders
The accordion carries no chrome of its own, so a bordered layout is pure composition: frame the stack with a rounded border and inset each item with horizontal padding so the dividers and text clear the edge.
"use client";
import { Accordion } from "@stridge/noctis";
const ITEMS = [
{
value: "billing",
title: "How does billing work?",
body: "We offer monthly and annual plans. Billing runs at the start of each cycle and you can cancel anytime.",
},
{
value: "security",
title: "Is my data secure?",
body: "Yes — data is encrypted at rest and in transit, with regular third-party security audits.",
},
{
value: "integrations",
title: "What integrations do you support?",
body: "We connect to 500+ tools, and you can build your own with the REST API and webhooks.",
},
];
// The accordion carries no chrome of its own, so a bordered layout is pure composition: frame the stack
// with a rounded border and inset each item with `px-4` so the dividers and text clear the edge.
export default function AccordionBorders() {
return (
<Accordion.Root defaultValue={["billing"]} className="w-full max-w-md rounded-lg border border-border">
{ITEMS.map(({ value, title, body }) => (
<Accordion.Item key={value} value={value} className="px-4">
<Accordion.Trigger>{title}</Accordion.Trigger>
<Accordion.Panel>{body}</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
);
}
Card
Wrap the stack in a bordered Surface for a self-contained FAQ card. Drop the accordion a heading rank below the card title (level) so the document outline stays well-formed.
Subscription & billing
Common questions about plans, payments, and cancellations.
"use client";
import { Accordion, Surface } from "@stridge/noctis";
const ITEMS = [
{
value: "plans",
title: "What plans do you offer?",
body: "Starter, Professional, and Enterprise — each adding storage, API access, and priority support.",
},
{
value: "billing",
title: "How does billing work?",
body: "Billing runs automatically at the start of each cycle. You'll get an emailed invoice after each payment.",
},
{
value: "cancel",
title: "How do I cancel?",
body: "Cancel anytime from account settings — no fees, and access runs to the end of the current period.",
},
];
// Wrap the accordion in a bordered Surface for a self-contained FAQ card. The card title sits a heading
// rank above the section triggers (`level={4}`), so the document outline stays well-formed.
export default function AccordionCard() {
return (
<Surface bordered className="flex w-full max-w-sm flex-col gap-4 p-6">
<div className="flex flex-col gap-1">
<h3 className="text-regular font-semibold text-foreground">Subscription & billing</h3>
<p className="text-small text-muted">Common questions about plans, payments, and cancellations.</p>
</div>
<Accordion.Root level={4} defaultValue={["plans"]}>
{ITEMS.map(({ value, title, body }) => (
<Accordion.Item key={value} value={value}>
<Accordion.Trigger>{title}</Accordion.Trigger>
<Accordion.Panel>{body}</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
</Surface>
);
}
Nested
Accordions nest for settings → sub-settings trees. Set level so each tier's triggers sit at the right rank in the document outline; a logical indent on the nested stack sets it apart from its parent.
"use client";
import { Accordion } from "@stridge/noctis";
export default function AccordionNested() {
return (
<Accordion.Root level={2} defaultValue={["workspace"]} className="w-full max-w-md">
<Accordion.Item value="workspace">
<Accordion.Trigger>Workspace</Accordion.Trigger>
<Accordion.Panel>
{/* A nested accordion: its triggers render at level 3 (one below the level-2 parent), so the
settings → sub-settings tree keeps a correct outline. A logical indent sets it apart. */}
<Accordion.Root level={3} defaultValue={["general"]} className="ms-4 w-auto">
<Accordion.Item value="general">
<Accordion.Trigger>General</Accordion.Trigger>
<Accordion.Panel>Workspace name, URL, and default landing view.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="members">
<Accordion.Trigger>Members</Accordion.Trigger>
<Accordion.Panel>Invite teammates and set their roles.</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="account">
<Accordion.Trigger>Account</Accordion.Trigger>
<Accordion.Panel>Email, password, and connected sign-in providers.</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
);
}
Find in page
By default a closed panel leaves the DOM. Add hiddenUntilFound to keep its content mounted but hidden, so the browser's find-in-page (Ctrl + F / ⌘ + F) matches text inside a collapsed section and auto-expands it — better for FAQs and SEO.
"use client";
import { Accordion } from "@stridge/noctis";
const SECTIONS = [
{
value: "refunds",
title: "How do refunds work?",
body: "Refunds are issued to the original payment method within five to ten business days of approval. You'll get an email confirmation once the refund is processed.",
},
{
value: "data",
title: "Where is my data stored?",
body: "Workspace data is stored in your selected region and encrypted at rest. Backups are retained for thirty days.",
},
];
export default function AccordionFindInPage() {
return (
// `hiddenUntilFound` keeps each closed panel in the DOM and findable: Ctrl+F (or ⌘-F) for text in a
// collapsed answer matches it and the browser auto-expands the section. Good for FAQs and SEO.
<Accordion.Root defaultValue={["refunds"]} className="w-full max-w-md">
{SECTIONS.map(({ value, title, body }) => (
<Accordion.Item key={value} value={value}>
<Accordion.Trigger>{title}</Accordion.Trigger>
<Accordion.Panel hiddenUntilFound>{body}</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
);
}
Disabled items
Mark an item disabled to show it exists while keeping it inert. It stays in the tab order and is announced as disabled, so the reader still learns the section is there — it just can't be expanded.
"use client";
import { Accordion } from "@stridge/noctis";
export default function AccordionDisabled() {
return (
<Accordion.Root defaultValue={["general"]} className="w-full max-w-md">
<Accordion.Item value="general">
<Accordion.Trigger>General settings</Accordion.Trigger>
<Accordion.Panel>Workspace name, language, and time zone.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="integrations" disabled>
<Accordion.Trigger>Integrations (Pro)</Accordion.Trigger>
<Accordion.Panel>Available on the Pro plan.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="advanced">
<Accordion.Trigger>Advanced</Accordion.Trigger>
<Accordion.Panel>Danger zone: export and delete the workspace.</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
);
}
Controlled
Drive the open sections yourself with value and onValueChange on Accordion.Root — the value is the array of open item values, so it works the same in single and multiple modes. Omit them (use defaultValue) to let the component own its state.
Open sections: release
"use client";
import { Accordion } from "@stridge/noctis";
import { useState } from "react";
export default function AccordionControlled() {
const [value, setValue] = useState<string[]>(["release"]);
return (
<div className="flex flex-col gap-3">
<Accordion.Root value={value} onValueChange={(next) => setValue(next as string[])} className="w-full max-w-md">
<Accordion.Item value="release">
<Accordion.Trigger>Release notes</Accordion.Trigger>
<Accordion.Panel>The latest changes shipped this week.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="roadmap">
<Accordion.Trigger>Roadmap</Accordion.Trigger>
<Accordion.Panel>What we are building next.</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
<p className="text-small text-muted">
Open sections: <span className="font-medium text-foreground">{value.join(", ") || "none"}</span>
</p>
</div>
);
}
Keyboard
Only Tab and Enter / Space are required by the WAI-ARIA accordion pattern; the arrow and Home / End keys are an enhanced convenience Base UI adds.
| Key | Action | Spec |
|---|---|---|
| Tab / Shift + Tab | Move focus to the next / previous trigger (or out of the accordion). | Required |
| Enter / Space | Toggle the focused section open or closed. | Required |
| ↓ / ↑ | Move focus to the next / previous trigger, looping at the ends. | Enhanced |
| Home / End | Focus the first / last trigger. | Enhanced |
Arrow-key navigation follows the accordion's orientation; a horizontal accordion uses ← / → instead, mirrored under RTL.
Accessibility
- Heading level. Each
Accordion.Triggerrenders inside an<h{level}>— setlevelonAccordion.Root(default3, overridable per trigger) so the stack nests correctly in the document outline. A wrong level reads as a broken outline to screen-reader users. - Region landmarks. Base UI labels each panel
role="region"by its trigger (aria-labelledby), so a screen-reader user can jump to and identify the disclosed content. Past six expandable items the role auto-steps back togroup(the APG guidance against flooding the landmark list);landmarkPanelsforces it on or off. - Chevron. Decorative and
aria-hidden; the trigger's label is the accessible name. Its rotation reads in both writing directions. - Motion. The height animation and the content fade are dropped under
prefers-reduced-motion; the state still swaps instantly.
Anatomy
Compose an accordion from its parts. Accordion.Root owns which sections are open (controlled via value / onValueChange, or uncontrolled via defaultValue) and the stack-wide settings.
Accordion.Root— the container. Props:size(sm·md),level(2–6, default3),landmarkPanels,multiple(defaultfalse),orientation(defaultvertical), plus the Base UIAccordion.Rootprops.Accordion.Item— one collapsible section. Pass avalueto control or initialise its open state; can bedisabled. Carries only the hairline rule dividing it from the next — pad it or wrap the stack to build a bordered or card layout.Accordion.Trigger— the full-width toggle button, rendered inside its own heading (<h{level}>, overridelevelper trigger). Base UI wiresaria-expandedandaria-controls; the rotating chevron sits in its own slot after the label.Accordion.Panel— the disclosed content. Its height animates via Base UI's measured value and respectsprefers-reduced-motion. Keep it mounted while closed withkeepMounted, or expose it to in-page find withhiddenUntilFound.
Every part carries a data-slot (accordion, accordion-item, accordion-header, accordion-trigger, accordion-trigger-icon, accordion-panel, accordion-panel-content) for host-side styling — pair it with the Base UI state attributes (data-open, data-panel-open, data-disabled, data-index, data-orientation) and with the root's data-size.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every accordion in that region retunes — e.g. .faq { --noctis-accordion-trigger-padding-block: var(--noctis-space-2); } tightens the trigger rows. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through. Expand a row for the full type and description.