Skip to content
Domphy

Accordion

Use accordion on a container of <details> elements to build a bordered accordion. In type: "single" mode (default), opening one item automatically closes all siblings. Pair with details patches on the child <details> elements.

Keyboard: Tab reaches every <summary> header. ArrowUp/ArrowDown move focus to the previous/next header (wrapping), and Home/End jump to the first/last — the WAI-ARIA APG accordion pattern's optional header navigation, in both single and multiple mode.

Props

PropTypeDefaultDescription
type"single" | "multiple""single"In single mode, opening one item closes all siblings.
colorValueOrState<ThemeColor>"neutral"Border and background tone.
accentColorValueOrState<ThemeColor>"primary"Focus-outline tone on summary elements.
Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import {
  type Listener,
  type PartialElement,
  toState,
  type ValueOrState,
} from "@domphy/core";
import {
  type ThemeColor,
  themeColor,
  themeDensity,
  themeSpacing,
} from "@domphy/theme";
import { focusRing } from "../utils/focusRing.js";

const radius = (listener: Listener) =>
  themeSpacing(themeDensity(listener) * 1.5);

/**
 * Container patch that groups `<details>` elements into a bordered accordion.
 * In `type: "single"` mode (default), opening one item closes all siblings.
 *
 * @param props.type - `"single"` (default) or `"multiple"`. Single mode auto-closes siblings.
 * @param props.color - Theme color tone for borders and backgrounds. Defaults to `"neutral"`.
 * @param props.accentColor - Accent color for focus rings on summary. Defaults to `"primary"`.
 * @example
 * { div: [
 *   { details: [{ summary: "Section A" }, { p: "Content A" }], $: [details()] },
 *   { details: [{ summary: "Section B" }, { p: "Content B" }], $: [details()] },
 * ], $: [accordion()] }
 */
function accordion(
  props: {
    type?: "single" | "multiple";
    color?: ValueOrState<ThemeColor>;
    accentColor?: ValueOrState<ThemeColor>;
  } = {},
): PartialElement {
  const { type = "single" } = props;
  const color = toState(props.color ?? "neutral", "color");
  const accentColor = toState(props.accentColor ?? "primary", "accentColor");

  return {
    _onMount: (node) => {
      const el = node.domElement;
      if (!el) return;

      if (type === "single") {
        const handler = (event: Event) => {
          const summary = (event.target as Element).closest("summary");
          if (!summary) return;
          const item = summary.closest("details") as HTMLDetailsElement | null;
          if (!item || item.parentElement !== el) return;
          if (!item.open) {
            Array.from(el.querySelectorAll(":scope > details")).forEach(
              (detail) => {
                if (detail !== item)
                  (detail as HTMLDetailsElement).open = false;
              },
            );
          }
        };
        el.addEventListener("click", handler);
        node.addHook("Remove", () => el.removeEventListener("click", handler));
      }

      // WAI-ARIA APG accordion pattern (optional, both modes): ArrowUp/Down
      // moves focus between headers, Home/End jump to the first/last. Tab
      // already reaches every summary; this is an accelerator on top of it.
      const onKeyDown = (event: Event) => {
        const key = (event as KeyboardEvent).key;
        if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(key)) return;
        const summary = (event.target as Element).closest("summary");
        if (!summary) return;
        const item = summary.closest("details") as HTMLDetailsElement | null;
        if (!item || item.parentElement !== el) return;
        const summaries = Array.from(
          el.querySelectorAll<HTMLElement>(":scope > details > summary"),
        );
        const index = summaries.indexOf(summary as HTMLElement);
        if (index === -1) return;
        let next = index;
        if (key === "ArrowDown") next = (index + 1) % summaries.length;
        else if (key === "ArrowUp")
          next = (index - 1 + summaries.length) % summaries.length;
        else if (key === "Home") next = 0;
        else if (key === "End") next = summaries.length - 1;
        event.preventDefault();
        summaries[next]?.focus();
      };
      el.addEventListener("keydown", onKeyDown);
      node.addHook("Remove", () =>
        el.removeEventListener("keydown", onKeyDown),
      );
    },
    style: {
      display: "flex",
      flexDirection: "column",
      borderRadius: radius,
      outline: (listener) =>
        `1px solid ${themeColor(listener, "border-strong", color.get(listener))}`,
      outlineOffset: "-1px",
      color: (listener) => themeColor(listener, "text", color.get(listener)),
      // No `overflow: hidden`: it rounded the end bands by clipping them, and
      // clipped the summary's focus ring with them (focusRing() draws 4px
      // outside the band). <summary> is the only child that paints a fill, so
      // the end bands round themselves instead.
      "& > details": {
        borderBottom: (listener) =>
          `1px solid ${themeColor(listener, "border-strong", color.get(listener))}`,
      },
      "& > details:first-child > summary": {
        borderTopLeftRadius: radius,
        borderTopRightRadius: radius,
      },
      // Open, the band is no longer the bottom edge — the body below it is.
      "& > details:last-child:not([open]) > summary": {
        borderBottomLeftRadius: radius,
        borderBottomRightRadius: radius,
      },
      "& > details:last-child": {
        borderBottom: "none",
        borderBottomLeftRadius: radius,
        borderBottomRightRadius: radius,
      },
      "& > details > summary:focus-visible": {
        boxShadow: (listener) => focusRing(listener, accentColor.get(listener)),
      },
    },
  };
}

export { accordion };
import { type PartialElement, toState, type ValueOrState } from "@domphy/core";
import {
  type ThemeColor,
  textToneOn,
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
  themeWeight,
} from "@domphy/theme";
import { focusRing } from "../utils/focusRing.js";

/**
 * Styles a native disclosure widget: a themed `<summary>` header with an
 * animated rotating chevron and an expand/collapse transition on the body
 * content. Apply to a `<details>` element.
 *
 * @hostTag details
 * @param props.color - Theme color tone (`ValueOrState<ThemeColor>`) for the body/summary. Defaults to "neutral".
 * @param props.accentColor - Accent color (`ValueOrState<ThemeColor>`) for the summary's focus ring. Defaults to "primary".
 * @param props.duration - Open/close transition duration in milliseconds. Defaults to 240.
 * @example { details: [{ summary: "More" }, { div: "Body" }], $: [details()] }
 */
function details(
  props: {
    color?: ValueOrState<ThemeColor>;
    accentColor?: ValueOrState<ThemeColor>;
    duration?: number;
  } = {},
): PartialElement {
  const { duration = 240 } = props;
  const color = toState(props.color ?? "neutral", "color");
  const accentColor = toState(props.accentColor ?? "primary", "accentColor");

  return {
    _onInsert: (node) => {
      if (node.tagName !== "details") {
        console.warn(`"details" primitive patch must use details tag`);
      }
    },
    style: {
      fontSize: (listener) => themeSize(listener, "inherit"),
      color: (listener) => themeColor(listener, "text", color.get(listener)),
      backgroundColor: (listener) =>
        themeColor(listener, "inherit", color.get(listener)),

      // No `overflow: hidden` here: the collapsing body clips itself (below) and
      // the root has no radius to clip to, so all it did was cut the summary's
      // focus ring, which focusRing() draws 4px outside the band.
      "& > summary": {
        backgroundColor: (listener) =>
          themeColor(listener, "shift-2", color.get(listener)),
        // shift-11 on a shift-2 header band = the design system's 9-step
        // contrast span. shift-10 was a gap of 8 and measured 4.33:1
        // (#636363 on #dbdbdb) — an axe `color-contrast` WCAG AA failure.
        color: (listener) =>
          themeColor(listener, "shift-11", color.get(listener)),
        fontSize: (listener) => themeSize(listener, "inherit"),
        listStyle: "none",
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        gap: themeSpacing(2),
        cursor: "pointer",
        userSelect: "none",
        fontWeight: themeWeight("medium"),
        paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4),
        // minHeight, not height: an inherited line-height taller than 10U has
        // to grow the band, not spill its own label out of it.
        minHeight: themeSpacing(10),
      },
      "& > summary::-webkit-details-marker": {
        display: "none",
      },
      "& > summary::marker": {
        content: `""`,
      },
      "& > summary::after": {
        content: `""`,
        width: themeSpacing(2),
        height: themeSpacing(2),
        flexShrink: 0,
        marginTop: themeSpacing(-0.5),
        borderInlineEnd: (listener) =>
          `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-9", color.get(listener))}`,
        borderBottom: (listener) =>
          `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-9", color.get(listener))}`,
        transform: "rotate(45deg)",
        transition: `transform ${duration}ms ease`,
      },
      "&[open] > summary::after": {
        transform: "rotate(-135deg)",
      },
      "& > summary:hover": {
        backgroundColor: (listener) =>
          themeColor(listener, "shift-3", color.get(listener)),
        // Summary text tracks the +3 hover fill: inherited shift-9 body text
        // measured 3.02:1 on it (light) / 3.63:1 (dark), below WCAG AA.
        color: (listener) =>
          themeColor(listener, textToneOn(3), color.get(listener)),
      },
      "& > summary:focus-visible": {
        borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2),
        boxShadow: (listener) => focusRing(listener, accentColor.get(listener)),
      },
      "& > :not(summary)": {
        maxHeight: 0,
        opacity: 0,
        overflow: "hidden",
        paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3),
        paddingTop: 0,
        paddingBottom: 0,
        transition: `max-height ${duration}ms ease, opacity ${duration}ms ease, padding ${duration}ms ease`,
      },
      "&[open] > :not(summary)": {
        // Non-clipping open state: a bounded max-height here (previously
        // themeSpacing(250) ≈ 1000px) silently CUT OFF body content taller
        // than the cap while fully open. CSS cannot transition max-height
        // to/from `none`, so the open transition is carried by opacity +
        // padding (both still animate over `duration`); max-height jumps
        // straight to none and tall content is never clipped.
        maxHeight: "none",
        opacity: 1,
        paddingTop: (listener) => themeSpacing(themeDensity(listener) * 1),
        paddingBottom: (listener) => themeSpacing(themeDensity(listener) * 3),
      },
    },
  };
}

export { details };