# Collapsible

One row that shows or hides the content under it, for detail most readers can skip.

Block: `smbk-collapsible`. Status: proposed. Figma: the Figma file, https://www.figma.com/community/file/1688324308358870630. Desktop and mobile: differ by presentation only.

## Options

| Axis | Values | Default | Meaning |
|---|---|---|---|
| `disabled` | on / off | off | The row dims and cannot be opened or closed. Figma: State Disabled. |

Parts: `smbk-collapsible__trigger`, `smbk-collapsible__icon`, `smbk-collapsible__label`, `smbk-collapsible__caret`, `smbk-collapsible__panel`

## Markup

```html
<details class="smbk-collapsible" open="">
  <summary class="smbk-collapsible__trigger">
    <i
      class="ph ph-list-dashes smbk-icon smbk-icon--size-md smbk-collapsible__icon"
      aria-hidden="true"
    ></i>
    <span class="smbk-collapsible__label">Show older releases</span>
    <i
      class="ph ph-caret-down smbk-icon smbk-icon--size-md smbk-collapsible__caret"
      aria-hidden="true"
    ></i>
  </summary>
  <div class="smbk-collapsible__panel">
    <p>Version 2.3 added saved filters, so a view you build once comes back the next time you open the board.</p>
    <p>Version 2.2 was the first public release, with shared boards, comments and an export to spreadsheets.</p>
  </div>
</details>
```

## React

```tsx
import { Collapsible } from './design-system/components/collapsible/collapsible.js';

<Collapsible icon="ListDashes" label="Show older releases" defaultExpanded>
  <p>Version 2.3 added saved filters, so a view you build once comes back the next time you open the board.</p>
  <p>Version 2.2 was the first public release, with shared boards, comments and an export to spreadsheets.</p>
</Collapsible>
```

## Stylesheet

```css
/* smbk-collapsible — Collapsible. Figma "Collapsible" 8196:2813 (State: Default, Hover, Focus, Disabled, Expanded).
 * One row that shows or hides what is under it. The block is a native details element, so opening, closing and the
 * keyboard are the browser's; the trigger is its summary. A row the medium control height, an optional icon, a Label LG
 * label and a caret at the far end that turns when open. Hover and keyboard focus lay the hover surface under the row,
 * focus with its ring. The panel unfolds with the expand purpose, as an accordion's item does (see the motion rules at
 * the end). */

.smbk-collapsible {
  box-sizing: border-box;
  width: 100%;
  color: var(--smbk-color-text-default);
}

.smbk-collapsible__trigger {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: var(--smbk-space-inline-md);
  min-height: var(--smbk-size-control-md);
  padding: 0 var(--smbk-space-inset-lg);
  border-radius: var(--smbk-radius-control-sm);
  list-style: none;
  cursor: pointer;
  font-family: var(--smbk-typography-label-lg-font-family);
  font-size: var(--smbk-typography-label-lg-font-size);
  font-weight: var(--smbk-typography-label-lg-font-weight);
  line-height: var(--smbk-typography-label-lg-line-height);
  letter-spacing: var(--smbk-typography-label-lg-letter-spacing);
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}

.smbk-collapsible__trigger::-webkit-details-marker {
  display: none;
}

@media (hover: hover) {
  .smbk-collapsible__trigger:hover {
    background: var(--smbk-color-surface-hover);
  }
}

.smbk-collapsible__trigger:focus-visible {
  outline: var(--smbk-focus-ring-width) solid var(--smbk-color-border-focus);
  outline-offset: var(--smbk-focus-ring-offset);
  background: var(--smbk-color-surface-hover);
}

.smbk-collapsible__icon {
  flex-shrink: 0;
  color: var(--smbk-color-icon-subtle);
  font-size: var(--smbk-size-icon-md);
}

/* The label takes the room the icon and the caret leave, and wraps rather than being cut short. */
.smbk-collapsible__label {
  flex: 1 1 auto;
  min-width: 0;
}

.smbk-collapsible__caret {
  flex-shrink: 0;
  color: var(--smbk-color-icon-subtle);
  font-size: var(--smbk-size-icon-md);
}

/* A collapsible the React component is closing (data-closing) is still open until its motion ends; its caret turns
   back as the motion starts. */
.smbk-collapsible[open]:not([data-closing]) > .smbk-collapsible__trigger .smbk-collapsible__caret {
  transform: rotate(180deg);
}

.smbk-collapsible__panel {
  display: flex;
  flex-direction: column;
  gap: var(--smbk-space-stack-sm);
  padding: var(--smbk-space-stack-xs) var(--smbk-space-inset-lg) var(--smbk-space-stack-md);
  color: var(--smbk-color-text-subtle);
  font-family: var(--smbk-typography-body-md-font-family);
  font-size: var(--smbk-typography-body-md-font-size);
  font-weight: var(--smbk-typography-body-md-font-weight);
  line-height: var(--smbk-typography-body-md-line-height);
  letter-spacing: var(--smbk-typography-body-md-letter-spacing);
}

/* Paragraphs in the panel are spaced by its gap, not by their own margins. */
.smbk-collapsible__panel > * {
  margin: 0;
}

/* ---- disabled: the row dims and takes no press ---- */

.smbk-collapsible--disabled > .smbk-collapsible__trigger {
  color: var(--smbk-color-text-disabled);
  cursor: not-allowed;
  pointer-events: none;
}

.smbk-collapsible--disabled .smbk-collapsible__icon,
.smbk-collapsible--disabled .smbk-collapsible__caret {
  color: var(--smbk-color-icon-disabled);
}

/* ---- motion: the panel unfolds with the expand purpose and folds back the same way, as an accordion's item does.
   The block's height grows from the trigger row to its own while the panel fades in from the purpose's slight blur,
   and the caret turns with it. The React component moves it, the same in every browser, and marks the block
   data-motion once it runs. While it moves (data-moving) it is clipped, with room to spare for the trigger's focus
   ring, and the foot of what shows fades out over a spacing step, or over what is still hidden when that is less.
   Markup written by hand is moved by the stylesheet alone where the browser can animate to a natural height, without
   the fading foot, and opens at once elsewhere. Under reduced motion the height lands at once and the fade and the
   blur still play. ---- */

/* How much of the panel shows while the block moves, from its top: the React component moves it with the height. */
@property --smbk-collapsible-reveal {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 100%;
}

.smbk-collapsible__caret {
  transition: transform var(--smbk-motion-expand-duration) var(--smbk-motion-expand-easing);
}

.smbk-collapsible[data-moving] {
  overflow: clip;
  overflow-clip-margin: calc(var(--smbk-focus-ring-width) + var(--smbk-focus-ring-offset));
}

/* The mask reads only how opaque its paint is, so the text color stands in for "shown". */
.smbk-collapsible[data-moving] > .smbk-collapsible__panel {
  mask-image: linear-gradient(currentcolor calc(var(--smbk-collapsible-reveal) - min(var(--smbk-space-16), 100% - var(--smbk-collapsible-reveal))), transparent var(--smbk-collapsible-reveal));
}

@supports (interpolate-size: allow-keywords) {
  .smbk-collapsible:not([data-motion]) {
    interpolate-size: allow-keywords;
  }

  .smbk-collapsible:not([data-motion])::details-content {
    block-size: 0;
    opacity: 0;
    filter: blur(var(--smbk-motion-expand-blur));
    overflow: clip;
    overflow-clip-margin: calc(var(--smbk-focus-ring-width) + var(--smbk-focus-ring-offset));
    transition-property: block-size, opacity, filter, content-visibility;
    transition-duration: var(--smbk-motion-expand-duration);
    transition-timing-function: var(--smbk-motion-expand-easing);
    transition-behavior: allow-discrete;
  }

  .smbk-collapsible:not([data-motion])[open]::details-content {
    block-size: auto;
    opacity: 1;
    filter: none;
  }

  /* Under reduced motion the height steps: at once as it opens, and only once it has faded as it closes. */
  @media (prefers-reduced-motion: reduce) {
    .smbk-collapsible:not([data-motion])::details-content {
      transition-duration: 0s, var(--smbk-motion-expand-duration), var(--smbk-motion-expand-duration), var(--smbk-motion-expand-duration);
      transition-delay: var(--smbk-motion-expand-duration), 0s, 0s, 0s;
    }

    .smbk-collapsible:not([data-motion])[open]::details-content {
      transition-delay: 0s;
    }
  }
}

@media (prefers-reduced-motion: reduce) {
  .smbk-collapsible__caret {
    transition: none;
  }
}

/* ---- touch: the trigger row takes an invisible press area ----
   On a touch screen an invisible area, centred on it, takes the press out to the comfortable target in each direction
   it falls short of it; its look, its focus ring and its place stay as drawn. */

@media (pointer: coarse) {
  .smbk-collapsible__trigger {
    position: relative;
  }

  .smbk-collapsible__trigger::after {
    content: '';
    position: absolute;
    inset: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
  }
}

/* ---- high contrast (forced colors) ---- */
/* In Windows high-contrast mode the browser drops fills and shadows; this puts the meaning back with the
   user's own colors. Nothing here applies outside that mode. */

@media (forced-colors: active) {
  .smbk-collapsible--disabled > .smbk-collapsible__trigger,
  .smbk-collapsible--disabled .smbk-collapsible__icon,
  .smbk-collapsible--disabled .smbk-collapsible__caret {
    color: GrayText;
  }
}
```

## Tokens it reads

```json
{
  "color": {
    "text": {
      "default": {
        "$type": "color",
        "$value": "{{color.grey.900}}",
        "$description": "Main text."
      },
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.700}}",
        "$description": "Secondary text: descriptions, metadata."
      },
      "disabled": {
        "$type": "color",
        "$value": "{{color.grey.500}}",
        "$description": "Text in unavailable controls."
      }
    },
    "surface": {
      "hover": {
        "$type": "color",
        "$value": "{{color.alpha.grey-800.15}}",
        "$description": "A row or option under the pointer or the keyboard highlight: menu options, table rows. Translucent, so it works on any surface."
      }
    },
    "border": {
      "focus": {
        "$type": "color",
        "$value": "{{color.red.600}}",
        "$description": "Keyboard focus ring on controls."
      }
    },
    "icon": {
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.700}}",
        "$description": "Quiet icons."
      },
      "disabled": {
        "$type": "color",
        "$value": "{{color.grey.400}}",
        "$description": "Icons in unavailable controls."
      }
    }
  },
  "space": {
    "16": {
      "$type": "dimension",
      "$value": "16px",
      "$description": "Raw space step, 16 px. Only for fixed distances inside a component; spacing that follows density uses space/inset, stack or inline."
    },
    "inline": {
      "md": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Gap between things side by side, medium."
      }
    },
    "inset": {
      "lg": {
        "$type": "dimension",
        "$value": "{{space.12}}",
        "$description": "Padding inside a component, large."
      }
    },
    "stack": {
      "sm": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Gap between things stacked one above another, small."
      },
      "xs": {
        "$type": "dimension",
        "$value": "{{space.4}}",
        "$description": "Gap between things stacked one above another, extra small."
      },
      "md": {
        "$type": "dimension",
        "$value": "{{space.12}}",
        "$description": "Gap between things stacked one above another, medium."
      }
    }
  },
  "size": {
    "control": {
      "md": {
        "$type": "dimension",
        "$value": "{{size.40}}",
        "$description": "Height of a medium control, where controls insert by default: 40 comfortable, 32 compact. Dashboard recipe: use compact density; 40 for the page’s one main action and for forms; 32 for toolbars and filters, the size used most; 28 for actions inside table rows and cards. On phones switch to comfortable density, or give small controls an invisible 44 px tap area (size/target/comfortable)."
      }
    },
    "icon": {
      "md": {
        "$type": "dimension",
        "$value": "{{size.20}}",
        "$description": "Icon box, medium."
      }
    },
    "target": {
      "comfortable": {
        "$type": "dimension",
        "$value": "{{size.44}}",
        "$description": "Comfortable touch target (44)."
      }
    }
  },
  "radius": {
    "control": {
      "sm": {
        "$type": "dimension",
        "$value": "{{radius.4}}",
        "$description": "Corners of controls, small."
      }
    }
  },
  "typography": {
    "label-lg": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "16px",
        "fontWeight": 500,
        "lineHeight": 1.4,
        "letterSpacing": "0px"
      },
      "$description": "Label LG: labels on controls and short UI text (buttons, chips, tabs, menu headings). Geist Medium, sentence case. Code and keys use Code SM and Code MD."
    },
    "body-md": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "16px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    }
  },
  "focus": {
    "ring": {
      "width": {
        "$type": "dimension",
        "$value": "{{border-width.2}}",
        "$description": "Thickness of the focus ring."
      },
      "offset": {
        "$type": "dimension",
        "$value": "{{space.2}}",
        "$description": "Gap between a control and its focus ring."
      }
    }
  },
  "motion": {
    "expand": {
      "duration": {
        "$type": "duration",
        "$value": "{{duration.320}}"
      },
      "easing": {
        "$type": "cubicBezier",
        "$value": "{{easing.cubic-out}}"
      },
      "blur": {
        "$type": "dimension",
        "$value": "2px",
        "$description": "How blurred the content is as it starts to open, and as it finishes closing."
      }
    }
  }
}
```

## Purpose

One row that shows or hides the content under it, so detail most readers can skip stays out of the way until someone asks for it: older releases under the latest one, the advanced settings under the usual ones. It is a single quiet row, with no card around it. Several sections that each open on their own belong in an accordion.

## Anatomy

- The block, a native disclosure element around the row and its panel.
- The trigger part, the row that opens and closes it: the disclosure's summary, the medium control height, padded at its sides.
- The icon part, optional, before the label.
- The label part, the row's words, taking the room the icon and the caret leave.
- The caret part, at the far end, which turns while the block is open.
- The panel part, what shows while open, set in from the sides to line up with the label's row.

## When to use

- Detail most readers can skip, under the content they need: earlier versions, the full list behind a short one, settings only some people change.
- One optional section on a page or in a form, where a card would weigh too much.

## When not to use

- Several sections, each a heading with its content. Use the [accordion](accordion.md).
- Content most people need. Show it.
- Moving between views of the same thing. Use [tabs](tabs.md).
- Opening a list of actions or choices over the page. Use the [dropdown](dropdown.md).

## Behavior

Pressing the row opens or closes the panel, and the caret turns with it. The panel unfolds with the expand motion and folds back the same way: the height grows from the row while the content fades in from a slight blur, and the foot of what shows fades rather than meeting the edge with a hard cut. A press halfway turns it round from where it is. The React component moves it, the same in every browser, and a page that holds the open state (`expanded`) moves it with the same motion. Markup written by hand is moved by the stylesheet where the browser can animate to a natural height, and opens at once elsewhere. Under reduced motion the height changes at once and the fade still plays.

Hover and keyboard focus lay a soft surface under the row; focus adds its ring. The block fills the width it is given. On a touch screen the row keeps its height and takes an invisible press area out to the comfortable touch target.

A browser's find in page can open it to show a match inside; the React component then reports it open.

## Accessibility

### What it does for you

- It is a native disclosure element, so Enter and Space open and close it and its open state is announced, with no script of yours.
- The row's name is its label. The icon and the caret are hidden from assistive technology.
- A disabled collapsible cannot be opened or closed, by the mouse, the keyboard or a screen reader's press. Its row stays in the tab order and is announced as unavailable.

### What you must do

- Write a label that says what the panel holds or what pressing does, such as "Show older releases".
- Wrap the label in a heading element when the panel is a section of the page with a heading of its own.
- Use the React component for a disabled collapsible, or leave it out. In markup written by hand only the mouse is kept off a disabled row, since a screen reader can still press it.

### How to test

- Tab to the row: the focus ring shows, in both themes and in a forced-color mode.
- Press Enter, then Space: each opens or closes the panel.
- With a screen reader, the row is read as a disclosure by its label, with its open or closed state.
- With text at 200% and the page 320 pixels wide, the label wraps and nothing is cut off.

### Keyboard

| Key | What it does |
|---|---|
| Tab | Moves focus to the row, a disabled one included. |
| Enter or Space | Opens the panel, or closes it when it is open. A disabled row does nothing. |

## Usage rules

### Do

- Keep the label to one line, and say what is inside.
- Start it closed, unless most readers need what it holds; then do not hide it.
- In markup written by hand, give the block `smbk-collapsible`, the summary the trigger part and the content the panel part, and mark the summary `aria-disabled="true"` with the disabled modifier on the block when it cannot be opened.

### Don't

- Hide something a reader must act on, such as an error or a required field.
- Stack several one under another to make a list of sections. That is an accordion.
