Accordion
Overview
The Accordion is a vertically stacked set of disclosure panels — each one expands to reveal its content and collapses to hide it. It is composed of five recipe parts: useAccordionRecipe() for the root container, useAccordionItemRecipe() for each panel and its divider, useAccordionTriggerRecipe() for the clickable header button, useAccordionContentRecipe() for the animated height wrapper, and useAccordionBodyRecipe() for the padded inner content. Each composable creates a fully configured recipe with color, variant, and size options — plus compound variants that handle the color-variant combinations automatically.
The Accordion recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS. The open/close animation is pure CSS — the content wrapper animates grid-template-rows from 0fr to 1fr based on a data-state attribute, so there is no JavaScript height measurement.
Why use the Accordion recipe?
The Accordion recipe helps you:
- Ship faster with sensible defaults: Get 3 colors, 2 visual styles, and 3 sizes out of the box with a single set of composable calls.
- Compose disclosure layouts: Five coordinated recipes (root, item, trigger, content, body) share the same variant axes, so your accordions stay internally consistent.
- Animate without JavaScript: The content wrapper animates height with a pure-CSS grid-rows transition driven by
data-state— no measuring, no layout thrash. - Maintain consistency: Compound variants ensure every color-variant combination follows the same design rules, including divider colors and dark mode overrides.
- Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.
- Stay type-safe: Full TypeScript support means your editor catches invalid color, variant, or size values at compile time.
Usage
Register the recipes
Add the Accordion recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:
import { styleframe } from 'virtual:styleframe';
import {
useAccordionRecipe,
useAccordionItemRecipe,
useAccordionTriggerRecipe,
useAccordionContentRecipe,
useAccordionBodyRecipe,
} from '@styleframe/theme';
const s = styleframe();
const accordion = useAccordionRecipe(s);
const accordionItem = useAccordionItemRecipe(s);
const accordionTrigger = useAccordionTriggerRecipe(s);
const accordionContent = useAccordionContentRecipe(s);
const accordionBody = useAccordionBodyRecipe(s);
export default s;
Build the component
Import the runtime functions from the virtual module and pass variant props to compute class names. The accordion is a disclosure widget, so you own a small amount of open/close state and reflect it on each trigger and content wrapper with a data-state attribute:
import { useState } from "react";
import {
accordion,
accordionItem,
accordionTrigger,
accordionContent,
accordionBody,
type AccordionProps,
} from "virtual:styleframe";
const items = [
{ value: "item-1", label: "Is it accessible?", content: "Yes. It follows the WAI-ARIA disclosure pattern." },
{ value: "item-2", label: "Is it animated?", content: "Yes. Height animates with a pure-CSS grid transition." },
];
export function Accordion({ color = "neutral", variant = "solid", size = "md" }: AccordionProps) {
const [open, setOpen] = useState<string | null>("item-1");
const stateOf = (value: string) => (open === value ? "open" : "closed");
return (
<div className={accordion({ color, variant, size })}>
{items.map((item) => (
<div key={item.value} className={accordionItem({ color, variant, size })}>
<h3>
<button
type="button"
className={accordionTrigger({ color, variant, size })}
data-state={stateOf(item.value)}
aria-expanded={open === item.value}
onClick={() => setOpen(open === item.value ? null : item.value)}
>
{item.label}
<svg className="accordion-trigger-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</h3>
<div className={accordionContent({ size })} data-state={stateOf(item.value)}>
{/* The grid child is the overflow clip; padding lives on the body inside it */}
<div>
<div className={accordionBody({ size })}>{item.content}</div>
</div>
</div>
</div>
))}
</div>
);
}
See it in action
Colors
The Accordion recipe includes 3 color variants: light, dark, and neutral. Like the Card, the Accordion uses neutral-spectrum colors designed for content surfaces rather than status communication. Each color sets the surface background, outer border, and divider color, with dark mode overrides handled by compound variants.
The neutral color adapts automatically: it uses a light appearance in light mode and a dark appearance in dark mode, making it the safest default for general-purpose accordions.
Color Reference
| Color | Token | Use Case |
|---|---|---|
light | @color.white / @color.gray-* | Light surfaces, stays light in dark mode |
dark | @color.gray-900 | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme |
neutral as your default accordion color. It adapts automatically to the user's color scheme, so you don't need to manage light and dark variants separately.Variants
Two visual style variants control how the accordion surface is rendered. Each variant is combined with the selected color through compound variants.
Solid
An enclosed, card-like surface: a filled background, an outer border, and rounded corners that clip the panels. Ideal when the accordion is a self-contained block on the page.
Ghost
Chromeless: no outer border or background, just the dividers between items. Ideal for embedding an accordion inside an existing surface (a card, a sidebar, a settings page) without nesting boxes.
Sizes
Three size variants from sm to lg control the border radius of the container plus the padding and type scale of the trigger and body.
Size Reference
| Size | Border Radius | Trigger Padding (V / H) | Trigger Font | Body Padding (T / B / H) |
|---|---|---|---|---|
sm | @border-radius.sm | @0.5 / @0.75 | @font-size.sm | @0.25 / @0.5 / @0.75 |
md | @border-radius.md | @0.75 / @1 | @font-size.md | @0.5 / @0.75 / @1 |
lg | @border-radius.lg | @1 / @1.25 | @font-size.lg | @0.75 / @1 / @1.25 |
size prop to each sub-recipe so the trigger and body padding stay in step with the container's border radius.Anatomy
The Accordion recipe is composed of five independent recipes that work together to form a disclosure stack:
| Part | Recipe | Role |
|---|---|---|
| Root | useAccordionRecipe() | Outer wrapper; surface background, border, and radius (solid) or chromeless (ghost) |
| Item | useAccordionItemRecipe() | A single panel with a bottom divider (suppressed on the last item) |
| Trigger | useAccordionTriggerRecipe() | The full-width header <button>; hover/focus/disabled states and the chevron that rotates on open |
| Content | useAccordionContentRecipe() | The height animator; transitions grid-template-rows based on data-state. Styles its grid child as an overflow: hidden clip |
| Body | useAccordionBodyRecipe() | The padded inner content, nested inside the content's overflow clip |
The content/body split is what makes the pure-CSS animation work: the content wrapper is a CSS grid whose single row animates between 0fr and 1fr. Its grid child is an overflow clip (overflow: hidden, min-height: 0, no padding) and the padded body sits inside that clip. Keeping the padding off the grid child is what lets the row collapse all the way to 0 instead of stopping at the padding height and leaving the panel partially visible when closed.
<div class="accordion(...)">
<div class="accordionItem(...)">
<h3>
<button class="accordionTrigger(...)" data-state="open">Trigger</button>
</h3>
<div class="accordionContent(...)" data-state="open">
<div><!-- overflow clip -->
<div class="accordionBody(...)">Body content</div>
</div>
</div>
</div>
</div>
data-state="open". Give your icon element the accordion-trigger-icon class and the recipe handles the rotation and its transition.Accessibility
The Accordion follows the WAI-ARIA Accordion pattern:
- Wrap each trigger in a heading. Place the trigger
<button>inside an<h2>–<h6>appropriate to the page outline so assistive technology can navigate panels by heading. - Use a real
<button>. The trigger must be a button so it is focusable and operable with both Enter and Space. - Reflect state with
aria-expanded. Setaria-expanded="true"on the trigger when its panel is open and"false"when closed. Mirror the same state ondata-stateso the recipe can animate the panel and chevron. - Respect
disabled. A disabled item's trigger getsdisabledon the button; the recipe dims it and removes pointer interactions.
<!-- Correct: heading-wrapped button with state reflected on aria-expanded -->
<h3>
<button type="button" class="accordionTrigger(...)" aria-expanded="true" data-state="open">
Shipping & returns
</button>
</h3>
grid-template-rows. If you support users who prefer reduced motion, gate the transition behind a motion-safe modifier or remove it in a prefers-reduced-motion media query.Customization
Overriding Defaults
Each accordion composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change. For example, default to the chromeless ghost look:
import { styleframe } from 'virtual:styleframe';
import {
useAccordionRecipe,
useAccordionTriggerRecipe,
} from '@styleframe/theme';
const s = styleframe();
const accordion = useAccordionRecipe(s, {
defaultVariants: {
color: 'neutral',
variant: 'ghost',
size: 'md',
},
});
const accordionTrigger = useAccordionTriggerRecipe(s, {
base: {
fontWeight: '@font-weight.semibold',
},
});
export default s;
Filtering Variants
If you only need a subset of the available variants, use the filter option to limit which values are generated. This reduces the output CSS and keeps your component API focused:
import { styleframe } from 'virtual:styleframe';
import { useAccordionRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the neutral color in the ghost style
const accordion = useAccordionRecipe(s, {
filter: {
color: ['neutral'],
variant: ['ghost'],
},
});
export default s;
API Reference
useAccordionRecipe(s, options?)
Creates the accordion root container recipe with the surface background, border, and radius.
Parameters:
| Parameter | Type | Description |
|---|---|---|
s | Styleframe | The Styleframe instance |
options | DeepPartial<RecipeConfig> | Optional overrides for the recipe configuration |
options.base | VariantDeclarationsBlock | Custom base styles for the container |
options.variants | Variants | Custom variant definitions for the recipe |
options.defaultVariants | Record<keyof Variants, string> | Default variant values for the recipe |
options.compoundVariants | CompoundVariant[] | Custom compound variant definitions for the recipe |
options.filter | Record<string, string[]> | Limit which variant values are generated |
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, ghost | solid |
size | sm, md, lg | md |
useAccordionItemRecipe(s, options?)
Creates the item recipe with a bottom divider whose color tracks the color axis. The divider is suppressed on the last item. Accepts the same parameters and variant axes as useAccordionRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, ghost | solid |
size | sm, md, lg | md |
useAccordionTriggerRecipe(s, options?)
Creates the trigger recipe — a full-width, reset <button> with hover, focus-visible, and disabled states. Its color axis drives the hover/active background; text color is inherited from the surface. The trailing .accordion-trigger-icon rotates 180° when the trigger has data-state="open".
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, ghost | solid |
size | sm, md, lg | md |
useAccordionContentRecipe(s, options?)
Creates the content recipe — a CSS grid wrapper that animates grid-template-rows from 0fr to 1fr when it has data-state="open". Its single grid child is styled as an overflow: hidden clip so the padded body inside can collapse fully. Accepts the same parameters as useAccordionRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, ghost | solid |
size | sm, md, lg | md |
useAccordionBodyRecipe(s, options?)
Creates the body recipe for the padded inner content. It sits inside the content recipe's overflow clip, so it carries only padding and typography (scaled by the size axis) — never overflow or min-height, which on the grid child would stop the panel from collapsing fully.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, ghost | solid |
size | sm, md, lg | md |
Best Practices
- Pass
colorandvariantconsistently: The root, item, and trigger share these axes so the surface, dividers, and hover states stay coherent. - Pass
sizeto each sub-recipe: The root controls border radius, but the trigger and body manage their own padding from thesizeprop. - Own the open state: The recipes are stateless and style off
data-state/aria-expanded. Keep open/closed state in your component and reflect it on both attributes. - Keep content mounted: The grid-rows animation needs the body in the DOM to collapse. Avoid
v-if/unmounting the content if you want the transition; toggledata-stateinstead. - Use
ghostwhen nesting: Reach forghostinside an existing surface andsolidwhen the accordion is a standalone block. - Filter what you don't need: If your accordion only uses one color or style, pass a
filteroption to reduce generated CSS.
FAQ
content (the grid animator) from body (the padded inner) is what makes the pure-CSS height animation collapse cleanly.grid-template-rows from 0fr (collapsed) to 1fr (expanded) when it carries data-state="open". Its grid child is an overflow: hidden clip with min-height: 0, and the padded body sits inside that clip — so the row collapses to exactly zero (padding on the grid child would floor it at the padding height). You only toggle the data-state attribute; the browser interpolates the height with no measurement.solid renders an enclosed, card-like surface with a background, an outer border, and rounded corners. ghost is chromeless — no background or border, just the dividers between items. Use solid for a standalone block and ghost when embedding the accordion inside an existing surface.light, dark, and neutral to provide surface variations that work across all content types.<button> inside an <h2>–<h6> appropriate to your page outline so assistive technology can navigate panels by heading. The heading carries no styling of its own; the recipe styles the button.data-state regardless of how many panels are open, so keep an array (or set) of open values in your component and let more than one item be open at a time.When you use the filter option, compound variants that reference filtered-out values are automatically removed, and default variants are adjusted if they reference a removed value. For example, filtering variant to ['ghost'] drops the solid surface compounds from the generated output.
Tooltip
A floating label component for supplementary information, composed of a content bubble and directional arrow. Supports multiple colors, visual styles, and sizes through the recipe system.
Avatar
A user avatar with an image and an automatic initials fallback — circular or square, with an optional status badge — built as a multi-part recipe system.