Comic Accordion
Hand-inked panels with a starburst toggle that spins open; the answer pops in on halftone dots.
betaComicAccordion.tsx
Examples
Basic
Is it really free?
Yes! MIT licensed. Use it in anything, no strings attached.
Does it need React 19?
React 18 or 19 both work. The styles come from Tailwind CSS v4.
Is there a dark mode?
Not yet. Comics are printed on paper, after all.
comic-accordion/basic.tsx
import { ComicAccordion } from "@streamuilib/react";
export default function ComicAccordionBasic() {
return (
<ComicAccordion
className="w-full max-w-lg"
defaultOpen={["free"]}
items={[
{ id: "free", title: "Is it really free?", content: "Yes! MIT licensed. Use it in anything, no strings attached." },
{ id: "react", title: "Does it need React 19?", content: "React 18 or 19 both work. The styles come from Tailwind CSS v4." },
{ id: "dark", title: "Is there a dark mode?", content: "Not yet. Comics are printed on paper, after all." },
]}
/>
);
}One at a time
Chapter 1: The spark
Opening one chapter closes the others.
Chapter 2: The fall
Only one is open at a time, using the native details name attribute.
Chapter 3: The comeback
No JavaScript needed.
comic-accordion/exclusive.tsx
import { ComicAccordion } from "@streamuilib/react";
export default function ComicAccordionExclusive() {
return (
<ComicAccordion
exclusive
className="w-full max-w-lg"
items={[
{ id: "1", title: "Chapter 1: The spark", content: "Opening one chapter closes the others." },
{ id: "2", title: "Chapter 2: The fall", content: "Only one is open at a time, using the native details name attribute." },
{ id: "3", title: "Chapter 3: The comeback", content: "No JavaScript needed." },
]}
/>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { ComicAccordion } from "@streamuilib/react";
<ComicAccordion
items={[
{ id: "free", title: "Is it free?", content: "Yes! MIT licensed." },
]}
/>API
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; title: ReactNode; content: ReactNode }[] | — | Sections in order. id must be unique. |
| exclusive | boolean | false | Only one item open at a time. |
| defaultOpen | string[] | [] | Ids of items open on first render. |
| className | string | — | Classes for the wrapper. |
Accessibility
- Native <details> and <summary>: Enter and Space toggle, screen readers hear expanded or collapsed, and find-in-page opens matches.
- The starburst toggle is decorative and hidden.
- The spin and pop-in stop when the user prefers reduced motion.
Source
packages/react/src/ComicAccordion.tsx
import { useId } from "react";
import { Plus } from "lucide-react";
import { cn } from "./cn";
import { halftone, sketchy } from "./comic";
import { SHAPES } from "./ComicBadge";
import type { AccordionProps } from "./Accordion";
export type ComicAccordionProps = AccordionProps;
/** Each item's hand-placed tilt, repeating. */
const TILTS = [-0.6, 0.5, -0.3, 0.7];
/**
* A comic-book accordion: hand-inked panels with shouty titles and a
* starburst toggle that spins as it opens; the answer pops in on halftone
* dots. Built on native <details>/<summary>, like Accordion: keyboard
* support, screen reader state, find-in-page, and no JavaScript needed.
*/
export function ComicAccordion({ items, exclusive = false, defaultOpen = [], className }: ComicAccordionProps) {
const group = useId();
return (
<div className={cn("flex flex-col gap-4", className)}>
{items.map((item, i) => (
<details
key={item.id}
name={exclusive ? group : undefined}
open={defaultOpen.includes(item.id)}
className={cn(
"sui-comic-acc group border-[3px] border-ink bg-paper-raised text-ink shadow-[4px_4px_0_var(--ink)] open:bg-lime-soft",
sketchy,
)}
style={{ rotate: `${TILTS[i % TILTS.length]}deg` }}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-3.5 font-bold [&::-webkit-details-marker]:hidden">
{item.title}
<span aria-hidden className="sui-comic-acc-burst relative grid size-10 shrink-0 place-items-center">
<svg viewBox="0 0 100 100" overflow="visible" className="absolute inset-0 size-full">
<path d={SHAPES.burst.d} className="fill-paper-raised stroke-ink group-open:fill-pink" strokeWidth={6} strokeLinejoin="round" />
</svg>
<Plus className="relative size-4" strokeWidth={3} />
</span>
</summary>
<div className="sui-comic-acc-body border-t-[3px] border-dashed border-ink px-5 py-4 leading-relaxed" style={halftone(10)}>
{item.content}
</div>
</details>
))}
</div>
);
}