Expandable list
Rows that open in place to show their detail, each with a round +/− toggle that swaps through a blur, grouped by a bracket hanging in the left margin.
Loading preview…
Show code
"use client";
import { Accordion } from "@base-ui/react/accordion";
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
const ICON_EASE = "duration-200 ease-[cubic-bezier(0.2,0,0,1)]";
/** + and − stacked; the one leaving shrinks to a quarter through a 4px blur. */
function ToggleIcon() {
const svg = cn(
"absolute size-3.5 transition-[opacity,scale,filter] motion-reduce:transition-none",
ICON_EASE,
);
const hidden = "opacity-0 scale-25 blur-[4px] motion-reduce:scale-100 motion-reduce:blur-none";
return (
<span
aria-hidden
className="relative grid size-6 flex-none place-items-center rounded-full bg-muted text-muted-foreground transition-colors duration-150 group-hover/trigger:text-foreground"
>
<svg
viewBox="0 0 24 24"
fill="none"
className={cn(svg, "group-data-[panel-open]/trigger:opacity-0 group-data-[panel-open]/trigger:scale-25 group-data-[panel-open]/trigger:blur-[4px]")}
>
<path d="M12 6v12M6 12h12" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</svg>
<svg
viewBox="0 0 24 24"
fill="none"
className={cn(
svg,
hidden,
"group-data-[panel-open]/trigger:opacity-100 group-data-[panel-open]/trigger:scale-100 group-data-[panel-open]/trigger:blur-none",
)}
>
<path d="M6 12h12" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</svg>
</span>
);
}
export type ExpandableListItemProps = {
/** The row's visible label. */
label: ReactNode;
/** Names the row for assistive tech when the label isn't text. */
"aria-label"?: string;
/** The detail revealed under the label. A row without one isn't expandable. */
children?: ReactNode;
/** Set by ExpandableList. */
value?: string;
};
export function ExpandableListItem({ label, children, value, "aria-label": ariaLabel }: ExpandableListItemProps) {
const row = "py-3.5";
const labelClass = "min-w-0 text-pretty text-lg leading-[1.4] text-foreground";
if (children == null || children === false) {
return (
<div className={row}>
<p className={labelClass}>{label}</p>
</div>
);
}
return (
<Accordion.Item value={value} className={row}>
<Accordion.Header className="m-0 w-full">
<Accordion.Trigger
aria-label={ariaLabel}
className="group/trigger flex w-full cursor-pointer items-center justify-between gap-4 rounded-md text-left outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
>
<span className={labelClass}>{label}</span>
<ToggleIcon />
</Accordion.Trigger>
</Accordion.Header>
{/* The height overshoots a touch on open, so the row lands rather than stops. */}
<Accordion.Panel className="h-(--accordion-panel-height) overflow-hidden transition-[height] duration-[280ms] ease-[cubic-bezier(0.22,1.14,0.48,1)] data-[starting-style]:h-0 data-[ending-style]:h-0 motion-reduce:transition-none">
<div className="flex flex-col gap-2 pt-2 pr-10 text-pretty text-lg leading-[1.4] text-muted-foreground">
{children}
</div>
</Accordion.Panel>
</Accordion.Item>
);
}
export type ExpandableListProps = {
/** A small label over the rows, e.g. "Paths not taken". */
title?: string;
/** "bad" colours the title and marks it with an ×, for options that were rejected. */
tone?: "bad";
/** Let more than one row stay open. */
multiple?: boolean;
className?: string;
children: ReactNode;
};
/**
* Rows that open in place to show their detail, each with a round +/−
* toggle. A bracket hangs in the left margin, so the rows read as one group
* without moving off the text column.
*/
export function ExpandableList({ title, tone, multiple = false, className, children }: ExpandableListProps) {
const items = Children.toArray(children).filter(isValidElement) as ReactElement<ExpandableListItemProps>[];
return (
<div
className={cn(
"relative flex w-full flex-col",
"before:pointer-events-none before:absolute before:inset-y-0 before:right-[calc(100%+16px)] before:w-1.5 before:rounded-l-[2px] before:border before:border-r-0 before:border-muted-foreground/40 before:content-['']",
className,
)}
>
{title ? (
<p
className={cn(
"mb-3 flex items-center gap-1 text-xs font-medium text-muted-foreground",
tone === "bad" && "text-destructive",
)}
>
{title}
{tone === "bad" ? (
<svg
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
aria-hidden
className="size-2.5 flex-none"
>
<path d="M2 2l6 6M8 2l-6 6" />
</svg>
) : null}
</p>
) : null}
<Accordion.Root
multiple={multiple}
className="flex flex-col [&>*+*]:shadow-[inset_0_1px_0_var(--border)]"
>
{items.map((child, index) => cloneElement(child, { key: index, value: String(index) }))}
</Accordion.Root>
</div>
);
}