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