Details
Use details on a details element. It styles a native disclosure widget: the summary child gets a themed header with a rotating chevron indicator, and the body content gets an expand/collapse transition. The color prop controls the surface and text tone. The accentColor prop controls the summary focus ring tone. The duration prop sets the transition speed in milliseconds (default 240).
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,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} 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`);
}
},
// Summary weight is design-system chrome for disclosure headers.
_doctorDisable: "inline-typography",
style: {
fontSize: (listener) => themeSize(listener, "inherit"),
color: (listener) => themeColor(listener, "text", color.get(listener)),
backgroundColor: (listener) =>
themeColor(listener, "inherit", color.get(listener)),
overflow: "hidden",
"& > summary": {
backgroundColor: (listener) =>
themeColor(listener, "shift-2", color.get(listener)),
color: (listener) =>
themeColor(listener, "shift-10", 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: 500,
paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4),
height: 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: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 };