Theme
On this page (13)
Component Skill — components/collapsible/component-skill.md

Native basis

<details> element providing native expand/collapse behavior with animated transitions.

Web Platform APIs

<details><summary>::details-content@starting-style

Classes

.collapsible.collapsible-trigger.collapsible-chevron.collapsible-content

Accessibility

• <details>/<summary> is natively accessible - keyboard and screen reader support built in

• No additional ARIA attributes needed

• Summary text should clearly describe the hidden content

§Basic

Simple collapsible with a trigger and content.

§Open by Default

Use the open attribute to start expanded.

§Multiple Sections

Stack multiple collapsibles for a settings-style panel.

§Colors

data-variant sets the surface: muted, primary, neutral, ghost (no border) - and highlight, plain while closed and primary once open. The heading, the marker and the content all follow the surface's text color.

§Custom colors

Set background and color on the .collapsible itself - the hover tint, the marker and the content text derive from its color, so any palette works.

§Bold headings

data-size scales the heading: sm, md (default), lg semibold, xl bold.

§Icons and emojis

A .collapsible-icon in front of the heading holds an icon or an emoji in a fixed box, so headings line up.

§Arrow and plus markers

No icon markup needed: data-marker='arrow' draws a chevron that turns up, 'plus' a + that becomes a -. data-marker-position='start' puts the marker before the heading.

§Custom open/close signs

Your own glyph: a .collapsible-chevron turns 180° (90° with data-turn='quarter', for a chevron-right) - or put two elements in the heading, .collapsible-when-closed and .collapsible-when-open, and the right one shows.

§Right to left

Everything is logical: in dir='rtl' the icon leads on the right, the marker sits on the left, a quarter-turned chevron mirrors.

§Density

Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.

§States

Machine contract - verified against collapsible.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseContent revealed - native <details open> (panel ⇄ clicking the summary).

§CSS view file

Styles for the collapsible component. Uses design tokens for colors, spacing, and radius.

@layer components {
  .collapsible {
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    overflow: hidden;
    color: var(--foreground);
    /* Two gotchas, both required for the panel to glide:
       1. `::details-content` must attach to the compound (`&::details-content`,
          NOT `& ::details-content` - the descendant form never matches, the
          pseudo's originating element is the subject itself).
       2. `block-size: auto` is a keyword - without interpolate-size the
          0→auto pair is non-interpolable and the transition silently snaps. */
    &::details-content {
      block-size: 0;
      overflow-y: clip;
      interpolate-size: allow-keywords;
      transition: block-size 200ms ease, content-visibility 200ms allow-discrete;
    }
    &[open]::details-content {
      block-size: auto;
    }
  }
  @starting-style {
    .collapsible[open]::details-content {
      block-size: 0;
    }
  }
  /* -- Density --------------------------------------------------
     data-density on the .collapsible root scales the padding between text
     and the outer border (trigger + content), ratio 0.75 / 1 / 1.25;
     comfortable matches the unsized default. */
  .collapsible:where([data-density="compact"]) {
    & .collapsible-trigger { padding: 0.5rem 0.75rem; }
    & .collapsible-content  { padding: 0 0.75rem 0.5rem; }
  }
  .collapsible:where([data-density="comfortable"]) {
    & .collapsible-trigger { padding: 0.75rem 1rem; }
    & .collapsible-content  { padding: 0 1rem 0.75rem; }
  }
  .collapsible:where([data-density="spacious"]) {
    & .collapsible-trigger { padding: 1rem 1.25rem; }
    & .collapsible-content  { padding: 0 1.25rem 1rem; }
  }
  .collapsible-trigger {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.75rem 1rem;
    font-size: 0.875rem;
    font-weight: 500;
    cursor: pointer;
    color: inherit;
    list-style: none;
    /* a tint of the text color - reads on any surface, custom colors included */
    &:hover {
      background-color: color-mix(in oklch, currentColor 7%, transparent);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
    &::-webkit-details-marker {
      display: none;
    }
  }
  .collapsible-chevron {
    width: 1rem;
    height: 1rem;
    margin-inline-start: auto;
    color: color-mix(in oklch, currentColor 60%, transparent);
    transition: transform 200ms ease;
    flex-shrink: 0;
    details[open] > summary > & {
      transform: rotate(180deg);
    }
  }
  .collapsible-content {
    padding: 0 1rem 0.75rem;
    font-size: 0.875rem;
    color: color-mix(in oklch, currentColor 72%, transparent);
    line-height: 1.6;
  }
  /* -- Leading icon / emoji ----------------------------------------
     .collapsible-icon in front of the heading: an svg or an emoji in a
     fixed 1.1em box, so headings line up whatever the glyph. */
  .collapsible-icon {
    display: inline-grid;
    place-items: center;
    width: 1.1em;
    height: 1.1em;
    flex-shrink: 0;
    font-style: normal;
    line-height: 1;
    & svg { width: 1em; height: 1em; }
  }
  /* -- Sizes: the heading's scale and weight (md == default) ------- */
  .collapsible {
    &[data-size="sm"] .collapsible-trigger { font-size: 0.8125rem; }
    &[data-size="md"] .collapsible-trigger { font-size: 0.875rem; }
    &[data-size="lg"] .collapsible-trigger { font-size: 1rem; font-weight: 600; }
    &[data-size="xl"] .collapsible-trigger { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.01em; }
    /* -- Variants: the surface (content follows its text color) ---- */
    &[data-variant="ghost"] { border-color: transparent; }
    &[data-variant="muted"] { background-color: var(--muted); border-color: transparent; }
    &[data-variant="primary"] {
      background-color: var(--primary);
      color: var(--primary-foreground);
      border-color: transparent;
    }
    &[data-variant="neutral"] {
      background-color: color-mix(in oklch, var(--foreground) 78%, var(--background));
      color: var(--background);
      border-color: transparent;
    }
    /* highlight: plain while closed, the primary surface once open */
    &[data-variant="highlight"] {
      transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease;
      &[open] {
        background-color: var(--primary);
        color: var(--primary-foreground);
        border-color: transparent;
      }
    }
  }
  /* -- Markers: a CSS-drawn open/close sign at the end of the heading -
     data-marker="arrow" (a chevron that turns up) or "plus" (+ that
     becomes -); data-marker-position="start" puts it before the text.
     For your own glyph use .collapsible-chevron (turns 180°, or 90° with
     data-turn="quarter") or swap two elements with .collapsible-when-open /
     .collapsible-when-closed. */
  .collapsible:is([data-marker="arrow"], [data-marker="plus"]) > .collapsible-trigger::after {
    content: '';
    flex-shrink: 0;
    margin-inline-start: auto;
    color: color-mix(in oklch, currentColor 65%, transparent);
    transition: rotate 200ms ease, translate 200ms ease, background-size 200ms ease;
  }
  .collapsible[data-marker="arrow"] > .collapsible-trigger::after {
    width: 0.45em;
    height: 0.45em;
    margin-inline-end: 0.2em;
    /* physical: a down/up arrow is the same in RTL */
    border-right: 2px solid currentColor;
    border-bottom: 2px solid currentColor;
    rotate: 45deg;
    translate: 0 -0.15em;
  }
  .collapsible[data-marker="arrow"][open] > .collapsible-trigger::after {
    rotate: -135deg;
    translate: 0 0.1em;
  }
  .collapsible[data-marker="plus"] > .collapsible-trigger::after {
    width: 0.8em;
    height: 0.8em;
    background:
      linear-gradient(currentColor 0 0) center / 100% 2px no-repeat,
      linear-gradient(currentColor 0 0) center / 2px 100% no-repeat;
  }
  .collapsible[data-marker="plus"][open] > .collapsible-trigger::after {
    background-size: 100% 2px, 2px 0;
    rotate: 180deg;
  }
  .collapsible[data-marker-position="start"] > .collapsible-trigger::after {
    order: -1;
    margin-inline-start: 0;
    margin-inline-end: 0.25em;
  }
  .collapsible-chevron[data-turn="quarter"] {
    details[open] > summary > & { transform: rotate(90deg); }
    /* RTL: a quarter-turned chevron-right points the other way */
    &:dir(rtl) { scale: -1 1; }
    details[open] > summary > &:dir(rtl) { transform: rotate(-90deg); }
  }
  /* swap two glyphs (📁 / 📂, "Show" / "Hide") instead of turning one */
  .collapsible:not([open]) > .collapsible-trigger .collapsible-when-open,
  .collapsible[open] > .collapsible-trigger .collapsible-when-closed {
    display: none;
  }
  .collapsible-trigger > :is(.collapsible-when-open, .collapsible-when-closed) {
    margin-inline-start: auto;
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .collapsible,
    .collapsible *,
    .collapsible::before,
    .collapsible::after,
    .collapsible *::before,
    .collapsible *::after,
    .collapsible::backdrop,
    .collapsible::details-content,
    .collapsible-chevron,
    .collapsible-chevron *,
    .collapsible-chevron::before,
    .collapsible-chevron::after,
    .collapsible-chevron *::before,
    .collapsible-chevron *::after,
    .collapsible-chevron::backdrop,
    .collapsible-content,
    .collapsible-content *,
    .collapsible-content::before,
    .collapsible-content::after,
    .collapsible-content *::before,
    .collapsible-content *::after,
    .collapsible-content::backdrop,
    .collapsible-trigger,
    .collapsible-trigger *,
    .collapsible-trigger::before,
    .collapsible-trigger::after,
    .collapsible-trigger *::before,
    .collapsible-trigger *::after,
    .collapsible-trigger::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub