data displayclient

accordion

sections that open up when you click them.

a set of tactile ui components that share one material and one light source.

yes. copy the files into your project and make them yours.

no. each component is a single file plus the design tokens in globals.css.

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add accordion

or by hand: drop this file into components/ui and import from there.

components/ui/Accordion.tsxtsx
"use client";
import { useState, useCallback, useId, ReactNode } from "react";
interface AccordionItem {
key: string;
title: string;
content: ReactNode;
defaultOpen?: boolean;
}
interface AccordionProps {
items: AccordionItem[];
allowMultiple?: boolean;
className?: string;
}
export function Accordion({ items, allowMultiple = false, className }: AccordionProps) {
const [openKeys, setOpenKeys] = useState<Set<string>>(() => {
const defaults = new Set<string>();
for (const item of items) {
if (item.defaultOpen) {
defaults.add(item.key);
}
}
return defaults;
});
const toggle = useCallback(
(key: string) => {
setOpenKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
if (allowMultiple) {
next.add(key);
} else {
next.clear();
next.add(key);
}
}
return next;
});
},
[allowMultiple]
);
const uid = useId();
return (
<div
className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
>
<div
className="bg-white rounded-[13px] overflow-hidden p-1"
style={{ boxShadow: "var(--panel-face-shadow)" }}
>
{items.map((item, index) => {
const isOpen = openKeys.has(item.key);
const isLast = index === items.length - 1;
const headerId = `${uid}-${item.key}-header`;
const panelId = `${uid}-${item.key}-panel`;
return (
<div
key={item.key}
className={[
"relative",
// Hairline between rows, inset so it doesn't touch the rounded corners
!isLast &&
"after:absolute after:left-3 after:right-3 after:bottom-0 after:h-px after:bg-neutral-100",
]
.filter(Boolean)
.join(" ")}
>
<h3>
<button
type="button"
id={headerId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(item.key)}
className={[
"group/acc w-full flex items-center justify-between gap-3 py-3 px-3 text-left text-[13.5px] font-medium cursor-pointer rounded-[10px]",
"transition-colors duration-150",
isOpen ? "text-neutral-900" : "text-neutral-700 hover:text-neutral-900 hover:bg-neutral-50",
].join(" ")}
>
<span>{item.title}</span>
{/* Plus that turns into a minus: says "more inside", not "navigate" */}
<span
aria-hidden
className="relative shrink-0 w-6 h-6 rounded-[7px] flex items-center justify-center text-neutral-500 group-hover/acc:text-neutral-900 transition-colors"
style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
>
<span className="absolute w-2.5 h-[1.5px] rounded-full bg-current" />
<span
className="absolute w-2.5 h-[1.5px] rounded-full bg-current"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(90deg)",
transition: "transform 300ms var(--ease-spring)",
}}
/>
</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
// Closed panels are removed from the tab order and the a11y tree
inert={!isOpen}
style={{
display: "grid",
gridTemplateRows: isOpen ? "1fr" : "0fr",
transition: "grid-template-rows 320ms var(--ease-out-soft)",
}}
>
<div className="overflow-hidden">
<div
className="px-3 pb-4 pt-0.5 text-[13px] text-neutral-600 leading-relaxed"
style={{
opacity: isOpen ? 1 : 0,
transform: isOpen ? "translateY(0)" : "translateY(-4px)",
transition: "opacity 240ms ease-out, transform 320ms var(--ease-out-soft)",
}}
>
{item.content}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

usage

tsx
import { Accordion } from "@blurrow/ui";
tsx
<Accordion items={[{ key: "1", title: "is it free?", content: "yes." }]} />

examples

multiple open

orders ship within two business days.

return anything within 30 days, no questions asked.

every product carries a two-year warranty.

props

straight from the source, so they're always up to date.

propsts
interface AccordionItem {
key: string;
title: string;
content: ReactNode;
defaultOpen?: boolean;
}
interface AccordionProps {
items: AccordionItem[];
allowMultiple?: boolean;
className?: string;
}