Details
Native <details> disclosure: themed summary with a rotating chevron, expand/collapse on the body.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ValueOrState<ThemeColor> | "neutral" | Tone for the body and summary. |
accentColor | ValueOrState<ThemeColor> | "primary" | Summary focus-ring tone. |
duration | number | 240 | Open/close transition duration in milliseconds. |
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:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- 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 * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
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 };