Styleframe Logo
Layout

Accordion

A stacked set of collapsible disclosure panels for progressively revealing content. Supports multiple colors, an enclosed or chromeless style, and sizes through the recipe system.

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:

src/components/accordion.styleframe.ts
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:

src/components/Accordion.tsx
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

ColorTokenUse Case
light@color.white / @color.gray-*Light surfaces, stays light in dark mode
dark@color.gray-900Dark surfaces, stays dark in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use 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

SizeBorder RadiusTrigger Padding (V / H)Trigger FontBody 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
Good to know: Pass the 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:

PartRecipeRole
RootuseAccordionRecipe()Outer wrapper; surface background, border, and radius (solid) or chromeless (ghost)
ItemuseAccordionItemRecipe()A single panel with a bottom divider (suppressed on the last item)
TriggeruseAccordionTriggerRecipe()The full-width header <button>; hover/focus/disabled states and the chevron that rotates on open
ContentuseAccordionContentRecipe()The height animator; transitions grid-template-rows based on data-state. Styles its grid child as an overflow: hidden clip
BodyuseAccordionBodyRecipe()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>
Pro tip: The chevron icon rotates 180° automatically when the trigger carries 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. Set aria-expanded="true" on the trigger when its panel is open and "false" when closed. Mirror the same state on data-state so the recipe can animate the panel and chevron.
  • Respect disabled. A disabled item's trigger gets disabled on 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 &amp; returns
    </button>
</h3>
Reduced motion: The expand/collapse transition animates 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:

src/components/accordion.styleframe.ts
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:

src/components/accordion.styleframe.ts
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;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

API Reference

useAccordionRecipe(s, options?)

Creates the accordion root container recipe with the surface background, border, and radius.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the container
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.compoundVariantsCompoundVariant[]Custom compound variant definitions for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, ghostsolid
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, ghostsolid
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, ghostsolid
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, ghostsolid
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, ghostsolid
sizesm, md, lgmd

Learn more about recipes →

Best Practices

  • Pass color and variant consistently: The root, item, and trigger share these axes so the surface, dividers, and hover states stay coherent.
  • Pass size to each sub-recipe: The root controls border radius, but the trigger and body manage their own padding from the size prop.
  • 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; toggle data-state instead.
  • Use ghost when nesting: Reach for ghost inside an existing surface and solid when the accordion is a standalone block.
  • Filter what you don't need: If your accordion only uses one color or style, pass a filter option to reduce generated CSS.

FAQ