Skip to examples
Bento / Kitchen sink
Bento / primitives

Disclosure

Content shown and hidden in place. Each trigger sits in a heading, so sections join the page outline.

Accordion

single · multiple · collapsible

single

Members you invite, and nobody else. Owners can change roles at any time.

multiple

Single keeps one open; with collapsible it can close to none. Multiple lets several stay open.

Sourcecomponents/disclosure/doc.ts · components/disclosure/accordion/doc.ts · components/disclosure/accordion/accordion.tsx · components/disclosure/accordion/accordion.module.css

components/disclosure/doc.ts

/**
 * disclosure — content shown and hidden in place.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Rules for every member
 *
 * R1  A trigger says what it reveals and whether it is open.
 * R2  Hidden content is removed from the reading order until shown.
 */
export {};

components/disclosure/accordion/doc.ts

/**
 * Accordion — sections that expand in place.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Accordion          type ("single" | "multiple"), collapsible?, value?,
 *                        defaultValue?, onValueChange?
 *     AccordionItem      value, REQUIRED; disabled?
 *     AccordionTrigger   the section title; level? (default 3)
 *     AccordionContent   the section body
 *
 * # Behaviour
 *
 * R1  Each trigger sits inside a heading of `level`, so sections join the
 *     outline and can be found by heading navigation.
 * R2  "single" keeps one section open at a time (collapsible lets it close
 *     too); "multiple" lets several stay open.
 * R3  Enter and Space toggle; the trigger announces expanded or collapsed. A
 *     chevron turns to show the state.
 * R4  Sections are separated by `--line` rules; the trigger is at least the
 *     lg control height, so each is easy to hit.
 */
export {};

components/disclosure/accordion/accordion.tsx

"use client";

import * as Primitive from "@radix-ui/react-accordion";
import type { ComponentPropsWithRef } from "react";

import { ChevronDown } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./accordion.module.css";

export type AccordionProps = ComponentPropsWithRef<typeof Primitive.Root>;
export type AccordionItemProps = ComponentPropsWithRef<typeof Primitive.Item>;
export type AccordionTriggerProps = ComponentPropsWithRef<
  typeof Primitive.Trigger
> & {
  /** The heading level wrapping the trigger, so sections join the outline. */
  level?: 2 | 3 | 4 | 5 | 6;
};
export type AccordionContentProps = ComponentPropsWithRef<
  typeof Primitive.Content
>;

/** type="single" opens one section at a time (collapsible to allow none);
 *  type="multiple" lets several stay open. */
export function Accordion({ className, ...props }: AccordionProps) {
  return <Primitive.Root {...props} className={cn(styles.root, className)} />;
}

export function AccordionItem({ className, ...props }: AccordionItemProps) {
  return <Primitive.Item {...props} className={cn(styles.item, className)} />;
}

export function AccordionTrigger({
  level = 3,
  className,
  children,
  ...props
}: AccordionTriggerProps) {
  const Tag = `h${level}` as const;
  return (
    <Primitive.Header asChild>
      <Tag className={styles.heading}>
        <Primitive.Trigger {...props} className={cn(styles.trigger, className)}>
          <span>{children}</span>
          <ChevronDown className={styles.chevron} aria-hidden="true" />
        </Primitive.Trigger>
      </Tag>
    </Primitive.Header>
  );
}

export function AccordionContent({
  className,
  children,
  ...props
}: AccordionContentProps) {
  return (
    <Primitive.Content {...props} className={cn(styles.content, className)}>
      <div className={styles.inner}>{children}</div>
    </Primitive.Content>
  );
}

components/disclosure/accordion/accordion.module.css

@layer primitive {
  .root {
    width: 100%;
  }
  .item {
    border-bottom: 1px solid var(--line);
  }
  .heading {
    margin: 0;
  }
  .trigger {
    display: flex;
    width: 100%;
    min-height: var(--control-lg);
    align-items: center;
    justify-content: space-between;
    gap: var(--space-6);
    padding: var(--space-5) var(--space-2);
    border: 0;
    border-radius: var(--radius-1);
    background: none;
    color: var(--ink);
    font: inherit;
    font-size: var(--text-body, var(--text-13));
    font-weight: var(--weight-strong);
    text-align: start;
    cursor: pointer;
  }
  .trigger:where(:hover:not(:disabled)) {
    color: var(--accent);
  }
  .trigger:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
  }
  .trigger:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }
  .chevron {
    width: 14px;
    height: 14px;
    flex: none;
    color: var(--ink-3);
    transition: transform var(--dur-2) var(--ease);
  }
  .trigger[data-state="open"] .chevron {
    transform: rotate(180deg);
  }
  .content {
    overflow: hidden;
    color: var(--ink-2);
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-body);
  }
  .inner {
    padding: 0 var(--space-2) var(--space-7);
  }
}