Skip to content

Collapsible

A collapsible hides one region behind one button. Reach for it when a piece of the page is genuinely secondary — the raw response under a formatted one, advanced settings, a build log, the rest of a long description — and the reader should be able to ignore it without scrolling past it.

collapsible/basic.tsx

Your export will include every project you own, as newline-delimited JSON, and a link will be emailed to you when it is ready.

Import

import { Collapsible } from "@forte-ui/react";

Collapsible is a namespace of three parts, and the nesting is fixed:

<Collapsible.Root>
  <Collapsible.Trigger>Heading</Collapsible.Trigger>
  <Collapsible.Panel>Body</Collapsible.Panel>
</Collapsible.Root>

Whether the panel is open is a boolean on Collapsible.RootdefaultOpen uncontrolled, open plus onOpenChange controlled. There is only ever one panel, so unlike Accordion.Root there is no list of values and nothing to name.

Examples

Plain

The default variant="plain" is the demo at the top of this page. It contributes no box of its own: the trigger is a bare text button the exact width of its label, the panel has no inline padding, and both line up with the prose around them. Hover is a colour change rather than a fill — the button is the label, so recolouring it marks the whole hit box exactly.

Contained

variant="contained" turns the pair into a card, sized and coloured to match one item of a contained accordion, so a lone collapsible can stand beside a set of them. The trigger becomes a full-width row with the chevron pinned to the inline-end edge, and hover fills the row — a cue confined to the label would claim only the label is pressable.

collapsible/contained.tsx
Between 3 and 5 working days to mainland addresses, and up to 8 to the islands. Orders over £60 ship free.

The variant is set once on Collapsible.Root and reaches the trigger and panel through context, so a mismatched pair — a card with a bare-text trigger inside it — is not a state that can be expressed.

Controlled

Pass open and onOpenChange to own the state, which is what it takes to drive the panel from something that is not its own trigger.

collapsible/controlled.tsx

onOpenChange 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.

A different marker

icon on Collapsible.Trigger replaces the chevron inside the same rotating, aria-hidden box, and icon={null} drops it. Anything you pass turns 180° when the panel opens, so pick a shape that is not symmetric about the horizontal axis — a plus or a dot reads as not having moved.

collapsible/custom-icon.tsx

Disabled

disabled on Collapsible.Root stops the panel 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 card is dimmed rather than just its trigger, so one that was left open does not show fully-enabled body text under a greyed-out header.

collapsible/disabled.tsx
Dimming the whole card, not just the header, is why an open panel cannot show fully-enabled body text under a greyed-out trigger.

Accessibility

Keyboard interactions
KeyBehaviour
TabMoves focus to the trigger, or past it. The trigger is an ordinary tab stop.
EnterOpens the panel, or closes it if it is already open.
SpaceOpens the panel, or closes it if it is already open.

Collapsible.Trigger renders a <button> with aria-expanded and an aria-controls pointing at the panel. Keep the trigger's text as its accessible name: the chevron is aria-hidden, and aria-expanded already announces open and closed, so a trigger reading "Details" needs no extra "show/hide" wording, and an icon-only trigger has no name at all.

A closed panel is removed from the DOM. Two props change that, and they answer different questions:

  • keepMounted leaves it mounted and hidden, which is what you want when the panel holds scroll position or a half-filled form that should survive being closed.
  • hiddenUntilFound hides it with hidden="until-found" instead, so the browser's own find-in-page can search the text and open the panel when it matches. It implies keepMounted. Use it for reference content — documentation, terms, a long answer — where a reader hunting for a phrase reasonably expects ⌘F to find it.

Under forced colors the contained card's border is pinned to CanvasText, since once shadows and backgrounds are stripped it is the only thing still holding the shape. Nothing paints Highlight: an open disclosure is not a selected one, and the chevron's rotation is geometry, so it survives that mode untouched and goes on saying whether the panel is open. A disabled collapsible is 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 --collapsible-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(--collapsible-panel-height);
  overflow: hidden;
  transition: height var(--forte-collapsible-duration) var(--forte-collapsible-ease);
}

.panel[data-starting-style],
.panel[data-ending-style] {
  height: 0;
}

It is a transition rather than a keyframe animation on purpose: closing the 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 the trigger whenever the panel is mounted and closed — which is exactly what keepMounted and hiddenUntilFound ask for. 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.

For the same reason there is no gap between the trigger and the panel: a flex gap on the root would still be painted while a mounted panel was closed, so the same markup would leave a band under the trigger or not depending on a prop that is meant to be invisible.

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 this 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 Collapsible.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 Collapsible.Root itself, through its className or an inline style. To move every collapsible 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.

Defaults are the plain variant; the Contained column gives the value only where the two differ.

Theming tokens for Collapsible
PropertyControlsDefault (plain)contained
--forte-collapsible-radiusCorner radius of the card. 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 open0pxvar(--forte-radius-surface)
--forte-collapsible-bgBackground behind the whole componenttransparentvar(--forte-color-panel)
--forte-collapsible-border-widthBorder around the card0px1px
--forte-collapsible-border-colorColour of that bordervar(--forte-color-border-muted)
--forte-collapsible-trigger-widthHow wide the button is. fit-content is what makes the plain trigger exactly as wide as its own labelfit-content100%
--forte-collapsible-trigger-pxInline padding of the trigger0pxvar(--forte-space-3)
--forte-collapsible-trigger-pyBlock padding of the triggervar(--forte-space-1)var(--forte-space-3)
--forte-collapsible-trigger-gapSpace between the label and the marker — a minimum, once the trigger is full widthvar(--forte-control-gap)var(--forte-space-4)
--forte-collapsible-trigger-bgTrigger background at resttransparent
--forte-collapsible-trigger-bg-hoverTrigger background on hover. Unset in plain, where the colour change below is the cue insteadvar(--forte-collapsible-trigger-bg)var(--forte-color-panel-hover)
--forte-collapsible-trigger-colorLabel colourvar(--forte-color-foreground)
--forte-collapsible-trigger-color-hoverLabel colour on hovervar(--forte-color-primary-text)var(--forte-collapsible-trigger-color)
--forte-collapsible-trigger-font-sizeLabel sizevar(--forte-font-size-2)
--forte-collapsible-trigger-font-weightLabel weightvar(--forte-font-weight-medium)
--forte-collapsible-icon-colorMarker colour when closedcurrentColorvar(--forte-color-foreground-subtle)
--forte-collapsible-icon-color-openMarker colour when opencurrentColorvar(--forte-color-foreground)
--forte-collapsible-icon-rotateHow far the marker turns when the panel opens180deg
--forte-collapsible-panel-pxInline padding of the panel content0pxvar(--forte-space-3)
--forte-collapsible-panel-ptBlock-start padding of the panel contentvar(--forte-space-2)0px
--forte-collapsible-panel-pbBlock-end padding of the panel content0pxvar(--forte-space-4)
--forte-collapsible-panel-colorBody text colourvar(--forte-color-foreground-muted)
--forte-collapsible-panel-font-sizeBody text sizevar(--forte-font-size-2)
--forte-collapsible-durationLength of the height change, and of the content's slidevar(--forte-duration-normal)
--forte-collapsible-easeCurve for both. Deliberately not a spring: overshooting height flashes a band of empty background under the last line before settlingvar(--forte-ease-standard)

variant="contained" is itself implemented by overriding these on the root, plus one structural declaration — overflow: hidden, which clips the trigger's hover fill and anything you paint behind the panel to the card's curve. That clip is also why the trigger's focus ring is inset in contained and outset in plain. Setting the knobs yourself on a contained collapsible 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-closed, data-panel-open, data-disabled — 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. Collapsible.Root also takes open, defaultOpen, onOpenChange and disabled; Collapsible.Panel takes keepMounted and hiddenUntilFound. All three parts take render for changing the underlying element.

Collapsible.Root

Props for CollapsibleRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
variantCollapsibleVariantplainHow much visual weight the disclosure carries. `"plain"` contributes no box of its own — a bare text button over its panel, for a "Show more" that lives inside a page. `"contained"` turns the pair into a card sized and coloured to match one item of a `contained` Accordion. Both are the same three parts with different knobs — nothing moves.

Collapsible.Trigger

Props for CollapsibleTrigger
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
iconReactNode<ChevronDownIcon />The affordance next to the label. 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.
labelClassNamestringAdditional class name(s) for the label box that wraps `children`. Applied after the internal styles.

Collapsible.Panel

Props for CollapsiblePanel
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
contentClassNamestringAdditional class name(s) for the inner element that holds the padding and the content. Applied after the internal styles.