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.

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>
  );
}

Used in