Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
ContainmentUpdatedFreeWCAG 2.2 AA

Accordion

Expandable accordion.

Preview

Live preview
@oshon-ai/components
Default (type='multiple') — any number open at once

Three-seed palette, 5-size type ramp, motion primitives. The Accordion body is dynamic in height — drop in anything.

Default-open per item
Body CTA link + disabled item — Figma 32348:16064

Install @oshon-ai/components, drop in the CSS reset, and call applyTheme() once. The CTA below uses the brand-primary token automatically.

Read the full guide

The bodyLink slot lives inside the inner card so the CTA aligns to the body content gutter, not the outer divider.

See the changelog
type='single' — radio-style exclusive open

Opening any other item closes this one.

Sizes

Header height and typography scale per the Figma size track; body padding scales with the header.

Header height and typography scale per the Figma size track; body padding scales with the header.

Header height and typography scale per the Figma size track; body padding scales with the header.

Header height and typography scale per the Figma size track; body padding scales with the header.

Header height and typography scale per the Figma size track; body padding scales with the header.

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add accordion

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Accordion } from '@oshon-ai/components';

export default function Example() {
  return <Accordion />;
}

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Accordion
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

title

Required header text. Rendered with Figma 14-px on-surface weight 600 at the default size. Wraps in a flex-1 container; truncates on overflow.

subtitle

Optional secondary line below the title. Rendered 12-px on-surface-muted. Truncates on overflow.

count

Optional numeric counter pill rendered next to the title. Surface flips from surface-muted (closed) to brand primary (open) via Tier-2 tokens.

leadingIcon

Optional icon rendered before the title. Inherits currentColor from the surrounding text so it tints with theme.

children

Body content shown when expanded inside the Figma inner-card container (radius 10, 1-px border, padding 16). Body grows with content.

bodyLink

Optional CTA slot rendered inside the inner card below body content. Accepts `{ label, href?, onClick? }`. Styled as brand-primary 12-px weight 600 link per Figma node 32348:16064 children.

disabled

Per-item flag that disables the header button, blocks toggle, and applies the Figma `mix-blend-mode: multiply` decoration plus 60% opacity (node 32348:16068). Disabled items NEVER bootstrap from `defaultOpen`.

Keyboard

Tab: focus next header. Shift+Tab: focus previous. Enter/Space: toggle the focused item. Headers are <button> with aria-expanded + aria-controls; bodies have role=region + aria-labelledby.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-05-14

Do / Don't

✓ Do

Multiple-open (default) with two items open by default
<Accordion defaultValue={['design', 'eng']}>
  <AccordionItem value="design" title="Design" count={4}>
    …
  </AccordionItem>
  <AccordionItem value="eng" title="Engineering" subtitle="Optional">
    …
  </AccordionItem>
</Accordion>
Radio-exclusive (single)
<Accordion type="single" defaultValue="basics">
  <AccordionItem value="basics" title="Basics">…</AccordionItem>
  <AccordionItem value="advanced" title="Advanced">…</AccordionItem>
</Accordion>
Controlled — manage state yourself
const [open, setOpen] = useState<string[]>([]);
<Accordion value={open} onValueChange={setOpen}>
  <AccordionItem value="a" title="A">…</AccordionItem>
</Accordion>
Item with body CTA link
<AccordionItem
  value="docs"
  title="Docs"
  bodyLink={{ label: 'Read the full guide', href: '/guide' }}
>
  …
</AccordionItem>

✗ Don't

Hardcoded surface colors
<AccordionItem className="bg-red-50 dark:bg-red-950" title="A">…</AccordionItem>

Breaks white-labeling. Every surface flows through Tier-2 tokens — if you need a different fill, swap the consumer-context tokens via applyTheme(), don't override the component.

Wrapping in a <button> or another interactive element
<button><Accordion>…</Accordion></button>

The header is already a <button>. Nesting an Accordion inside another button creates a "button-in-button" violation (WCAG 2.1 4.1.2) and disables the inner Enter/Space handler.

Duplicate `value` props across items
<AccordionItem value="x" title="A">…</AccordionItem>
<AccordionItem value="x" title="B">…</AccordionItem>

The Accordion uses `value` as the open-state key. Duplicates conflate the two items — toggling one toggles both. Use unique stable identifiers per item.

Design rationale

Compound API mirrors Radix Accordion + Headless UI Disclosure so consumers transferring from those libraries get a near-identical surface. type='multiple' is the default per the user's spec — most SaaS surfaces (filter sidebars, settings groups) want independent open-state. The 1-px bottom border on every header + body wrapper (with no per-item top border) produces continuous dividers without doubled lines at item joins. Body content lives inside a Figma-spec inner card (radius 10, 1-px border, padding 16) so the open state reads as a self-contained sub-surface rather than free-floating text. Width clamps to 256–400 px per Figma 32348:16055; consumers who need wider/narrower drop a `max-w-*` / `min-w-*` className.