Accordion
Hand-ruled sections with a drawn chevron that turns over as they open.
Built on Base UI AccordionYes. Every component is built on Base UI, checked with axe in both themes, and keyboard-tested. The drawing is decoration; the semantics are the primitive's.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
const faq = [
["Is it accessible?", "Yes. Every component is built on Base UI, checked with axe in both themes, and keyboard-tested. The drawing is decoration; the semantics are the primitive's."],
["Does it work without JavaScript?", "The page renders with every stroke already drawn. Scripts add the measuring and the drawing in."],
["Can I change the pen?", "Per component, for a section, or for the whole app: see the customizer."],
];
export default function AccordionDemo() {
return (
<Accordion defaultValue={["item-0"]} className="max-w-lg">
{faq.map(([question, answer], i) => (
<AccordionItem key={i} value={`item-${i}`}>
<AccordionTrigger seed={`acc-${i}`}>{question}</AccordionTrigger>
<AccordionContent>{answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
}Installation
pnpm dlx shadcn@latest add @ballpoint/ accordionOr copy the source
"use client";
import { useMemo } from "react";
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { cn } from "@/lib/utils";
import { useInkSeed } from "@/hooks/use-ink-box";
import { InkSvg, Stroke, inkRules } from "@/lib/ink";
import { chevronStroke } from "@/lib/ink-sketch";
function Accordion({ className, style, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
style={typeof style === "function" ? (state) => ({ ...inkRules, ...style(state) }) : { ...inkRules, ...style }}
{...props}
/>
);
}
/** Items are ruled off from each other by hand (see inkRules). */
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn(
"relative not-last:after:pointer-events-none not-last:after:absolute not-last:after:inset-x-0 not-last:after:-bottom-[2.5px] not-last:after:h-[5px] not-last:after:bg-ink-4 not-last:after:[mask-image:var(--ink-rule-1)] not-last:after:[mask-size:100%_100%] nth-[3n+2]:after:[mask-image:var(--ink-rule-2)] nth-[3n]:after:[mask-image:var(--ink-rule-3)]",
className,
)}
{...props}
/>
);
}
/** The heading row; its drawn chevron turns over when the item opens. */
function AccordionTrigger({ className, children, seed, ...props }: AccordionPrimitive.Trigger.Props & { seed?: string | number }) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 cursor-pointer items-center justify-between gap-4 py-3.5 text-left text-base font-bold transition-colors outline-none",
"hover:text-ink-2 focus-visible:outline-solid focus-visible:outline-[1.5px] focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:[border-radius:var(--hand-radius)]",
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
className,
)}
{...props}
>
{children}
<Chevron seed={seed} />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function Chevron({ seed }: { seed?: string | number }) {
const s = useInkSeed(seed);
const d = useMemo(() => chevronStroke(s, 12, 7), [s]);
return (
<span
data-slot="accordion-trigger-icon"
aria-hidden="true"
className="relative size-4 shrink-0 text-ink-3 transition-transform duration-(--dur-state) ease-out group-data-panel-open/accordion-trigger:rotate-180 motion-reduce:transition-none"
>
<InkSvg box={[-2, -4.5, 16, 16]} className="inset-0 size-full">
<Stroke d={d} width={1.6} />
</InkSvg>
</span>
);
}
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="h-(--accordion-panel-height) overflow-hidden text-base text-ink-2 transition-[height] duration-(--dur-state) ease-out data-ending-style:h-0 data-starting-style:h-0 motion-reduce:transition-none"
{...props}
>
<div className={cn("pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4", className)}>{children}</div>
</AccordionPrimitive.Panel>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };Usage
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
<Accordion>
<AccordionItem value="a">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>Yes.</AccordionContent>
</AccordionItem>
</Accordion>API reference
- multiple
booleandefaultfalse - On Accordion: lets more than one item stay open.
- defaultValue
string[] - On Accordion: the items open to begin with.
- AccordionTrigger seed
string | number - Pins the chevron's drawing.
Every other prop goes to Base UI's Accordion.