Accordion
An accordion hides a list of sections behind their own headings and reveals one at a time. Reach for it when the reader needs the titles more than the bodies — a FAQ, a settings screen with rarely-used groups, a log of steps where only the failing one matters. If the reader has to compare two sections, or search the page for a phrase that lives inside one, leave the content on the page instead: collapsed panels are unmounted by default, so neither the eye nor ⌘F can reach them.
Import
import { Accordion } from "@forte-ui/react";Accordion is a namespace of five parts: Accordion.Root, Accordion.Item, Accordion.Header, Accordion.Trigger and Accordion.Panel. The nesting is fixed — an Item holds a Header and a Panel, and the Trigger goes inside the Header:
<Accordion.Root>
<Accordion.Item value="one">
<Accordion.Header>
<Accordion.Trigger>Heading</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>Body</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>The Header looks like a wrapper worth skipping, and it is not: it is the <h3> that puts the section into the page's heading outline, which is how a screen-reader user jumps between sections without tabbing through every trigger. The Trigger inside it is the button.
Which panels are open is a list, on Accordion.Root, even when only one may be open at a time: defaultValue={["shipping"]} uncontrolled, or value plus onValueChange controlled. [] means everything is closed.
Examples
Divided
The default variant="divided" is the demo at the top of this page: no box around the set, a hairline between rows, and a hover fill the full width of the row. Use it when the accordion is the page — a FAQ, a long settings column — where a border round every row would add a second grid to read.
Contained
variant="contained" separates the rows into cards: each item gets a border, a surface and a radius, and a gap opens up between them. Use it when the accordion sits among other content and needs an edge of its own.
The variant is set once on Accordion.Root and reaches the items, triggers and panels through context, so a mismatched pair — contained cards with divided hairlines between them — is not a state that can be expressed.
Several panels at once
multiple on Accordion.Root lets any number of panels be open together, and defaultValue then usually names more than one. Without it, opening a panel closes the one before it.
Controlled
Pass value and onValueChange to own the state — which is what "expand all" needs, since it is a state change no trigger produced.
onValueChange also receives an event details object whose reason is 'trigger-press' for a user's click or key press, so a change your own code made can be told apart from one the user made.
Disabled item
disabled on an Accordion.Item stops it opening. The trigger stays in the tab order — a control a keyboard user cannot reach is a control they cannot discover the existence of — but pressing it does nothing, and the whole item is dimmed rather than just its heading, so an item that was left open does not show fully-enabled body text under a greyed-out header.
disabled on Accordion.Root does the same to every item at once.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the next trigger, or out of the accordion past the last one. Every trigger is an ordinary tab stop. |
| Shift then Tab | Moves focus to the previous trigger. |
| Enter | Opens the focused panel, or closes it if it is already open. |
| Space | Opens the focused panel, or closes it if it is already open. |
There are deliberately no arrow-key semantics. The APG dropped roving focus for accordions — a heading followed by its content is document structure, not a composite widget, and making Tab skip past the triggers cost more than the arrow keys gained. Base UI still accepts orientation and loopFocus for compatibility, but neither does anything any more, so this component does not forward them.
Each Accordion.Header renders an <h3> carrying a <button> with aria-expanded and an aria-controls pointing at its panel; the panel is a role="region" labelled by its trigger. <h3> is right only if the accordion sits under an <h2> — pass render={<h2 />} (or whichever level fits) to keep the outline ordered:
<Accordion.Header render={<h2 />}>
<Accordion.Trigger>Billing</Accordion.Trigger>
</Accordion.Header>A closed panel is removed from the DOM. Two props change that, and they answer different questions:
keepMountedonAccordion.RootorAccordion.Panelleaves it mounted and hidden, which is what you want when a panel holds scroll position or a half-filled form that should survive being closed.hiddenUntilFoundhides it withhidden="until-found"instead, so the browser's own find-in-page can search the text and open the panel when it matches. It implieskeepMounted. Use it for reference content — documentation, terms, a long FAQ — where a reader hunting for a phrase reasonably expects⌘Fto find it.
Under forced colors both dividing lines are pinned to CanvasText, since a card whose border was replaced by a system colour is the only thing still holding the shape once shadows and backgrounds are stripped. Nothing paints Highlight: an open row is not a selected row, and the chevron's rotation is geometry, so it survives that mode untouched and goes on saying which rows are open. Disabled items are painted GrayText rather than relying on opacity, which forced colors does not honour.
Motion
The height animation runs without a frame of JavaScript. Base UI measures the panel and publishes the result on the element as --accordion-panel-height — a px value while the transition runs, then auto once it settles, so the content can reflow afterwards. The stylesheet consumes it directly:
.panel {
height: var(--accordion-panel-height);
overflow: hidden;
transition: height var(--forte-accordion-duration) var(--forte-accordion-ease);
}
.panel[data-starting-style],
.panel[data-ending-style] {
height: 0;
}It is a transition rather than a keyframe animation on purpose: closing a panel while it is still opening reverses from wherever it has got to instead of snapping back to full height first. Base UI works out which of the two it is dealing with by reading the panel's computed transition-duration — a duration of exactly 0s reads as "no transition" and the measure-and-animate path is skipped entirely, which is one more reason the library's duration tokens bottom out at 1ms and never at zero.
The panel's padding is not on the panel. A padding box survives a zero content box, so padding on the collapsing element would leave a band of empty space under every closed row; it lives on an inner element instead, which is also what gives the content something of its own to move on. It starts a little high and at zero opacity and settles as the panel grows — travelling downward, because the panel clips from below and that is the direction that reads as being uncovered.
The chevron flips on a spring (--forte-ease-spring-snappy, paired with its own duration token — a shorter one truncates the curve mid-bounce). Its angle is not gated on --forte-motion-ok: under reduced motion the chevron must still end up pointing the other way, because that is the state, and only the trip between the two states is motion.
There is no prefers-reduced-motion block in the accordion stylesheet, and none anywhere else in a component file. The tokens do it: --forte-duration-normal collapses from 240ms to 120ms and --forte-travel-sm to 0px, so the panel snaps open and the content fades in place without travelling. Durations shorten rather than reaching 0s, so every transition still fires.
Theming
Every property below is declared on Accordion.Root, which is also why an ancestor is the wrong place to set one — the root element's own declaration beats an inherited value. Override them on Accordion.Root itself, through its className or an inline style. To move every accordion at once, re-point the global tokens in the Default column instead: --forte-color-*, --forte-radius-*, --forte-space-*, --forte-control-* and --forte-font-* are only read here, never re-declared, so those do inherit from :root or a theme scope.
| Property | Controls | Default |
|---|---|---|
--forte-accordion-gap | Space between items | 0px |
--forte-accordion-item-radius | Corner radius of an item. The trigger takes the same radius, so its focus ring follows the card rather than being shaved by the card's own clip — squaring off its bottom two corners while the panel is open | 0px |
--forte-accordion-item-bg | Background of an item | transparent |
--forte-accordion-item-border-width | Border around an item (the contained card) | 0px |
--forte-accordion-item-border-color | Colour of that border | var(--forte-color-border-muted) |
--forte-accordion-divider-width | Thickness of the hairline between rows (divided only) | 1px |
--forte-accordion-divider-color | Colour of that hairline | var(--forte-color-border-muted) |
--forte-accordion-trigger-px | Inline padding of a row | var(--forte-space-3) |
--forte-accordion-trigger-py | Block padding of a row | var(--forte-space-3) |
--forte-accordion-trigger-gap | Minimum space between the label and the icon, so a label long enough to fill the row still cannot run into it | var(--forte-space-4) |
--forte-accordion-trigger-bg | Row background at rest | transparent |
--forte-accordion-trigger-bg-hover | Row background on hover — one step up from whatever the row sits on | var(--forte-color-panel) |
--forte-accordion-trigger-color | Heading colour | var(--forte-color-foreground) |
--forte-accordion-trigger-font-size | Heading size | var(--forte-font-size-2) |
--forte-accordion-trigger-font-weight | Heading weight | var(--forte-font-weight-medium) |
--forte-accordion-icon-color | Chevron colour when closed | var(--forte-color-foreground-subtle) |
--forte-accordion-icon-color-open | Chevron colour when open | var(--forte-color-foreground) |
--forte-accordion-icon-rotate | How far the chevron turns when the panel opens | 180deg |
--forte-accordion-panel-px | Inline padding of the panel content | var(--forte-accordion-trigger-px) |
--forte-accordion-panel-pb | Block-end padding of the panel content. There is no block-start knob — the trigger's own bottom padding already separates the two | var(--forte-space-4) |
--forte-accordion-panel-color | Body text colour | var(--forte-color-foreground-muted) |
--forte-accordion-panel-font-size | Body text size | var(--forte-font-size-2) |
--forte-accordion-duration | Length of the height change, and of the content's slide | var(--forte-duration-normal) |
--forte-accordion-ease | Curve for both. Deliberately not a spring: overshooting height flashes a band of empty background under the last line before settling | var(--forte-ease-standard) |
variant="contained" is itself implemented by overriding five of these on the root — --forte-accordion-gap, --forte-accordion-item-radius, --forte-accordion-item-bg, --forte-accordion-item-border-width and --forte-accordion-trigger-bg-hover. Setting them yourself on a contained accordion still works: the library's rules live in the forte.components cascade layer, and an unlayered rule of yours beats a layered one whatever its specificity.
The parts also expose their state as data attributes — data-variant, data-open, data-panel-open, data-disabled, data-index — so a Tailwind arbitrary variant such as data-[panel-open]:... can target them without a wrapper element.
API reference
Each part forwards every prop it does not consume to its Base UI counterpart, so the tables below list what forte-ui adds or changes the default of. Accordion.Root also takes value, defaultValue, onValueChange, multiple, disabled, keepMounted and hiddenUntilFound; Accordion.Item takes value, disabled and onOpenChange; Accordion.Panel takes keepMounted and hiddenUntilFound. All five parts take render for changing the underlying element.
Accordion.Root
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
variant | AccordionVariant | divided | How much visual weight the set carries. `"divided"` is a flat list with a hairline between rows; `"contained"` turns every item into a separated card. Both are the same parts with different knobs — nothing moves. |
Accordion.Item
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Accordion.Header
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Accordion.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
icon | ReactNode | <ChevronDownIcon /> | The affordance at the inline-end of the row. Defaults to a chevron that rotates when the panel opens. Pass your own node to replace it — it is wrapped in the same rotating, `aria-hidden` box, so a plus or a caret needs no extra wiring — or `null` to drop it entirely. |
labelClassName | string | Additional class name(s) for the label box that wraps `children`. Applied after the internal styles. |
Accordion.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
contentClassName | string | Additional class name(s) for the inner element that holds the padding and the content. Applied after the internal styles. |