blode
uiaccordionsource: ok · hit
Accordion
A vertically stacked set of interactive headings that reveal or hide associated content.
npx shadcn add "https://blode.co/ui/r/accordion.json"free
Interaction
scroll-revealaccordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumui-primitives@base-ui/react
animationmotion
Files1 · source cached 10/14/2026, 3:09:22 PM
ui/accordion.tsx
"use client";
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { AnimatePresence, motion } from "motion/react";
import type { HTMLMotionProps, Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
interface AccordionItemContextType {
canAnimate: boolean;
isOpen: boolean;
setCanAnimate: (canAnimate: boolean) => void;
setIsOpen: (open: boolean) => void;
}
const AccordionItemContext = createContext<AccordionItemContextType | undefined>(undefined);
const useAccordionItem = (): AccordionItemContextType => {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error("useAccordionItem must be used within an AccordionItem");
}
return context;
};
type AccordionValue = string | string[];
type AccordionProps = Omit<
ComponentProps<typeof AccordionPrimitive.Root>,
"defaultValue" | "multiple" | "onValueChange" | "value"
> & {
type?: "single" | "multiple";
collapsible?: boolean;
value?: AccordionValue;
defaultValue?: AccordionValue;
onValueChange?: (value: AccordionValue) => void;
};
const normalizeAccordionValue = (value: AccordionValue | undefined): string[] | undefined => {
if (value === undefined) {
return undefined;
}
if (Array.isArray(value)) {
return value;
}
return value === "" ? [] : [value];
};
const Accordion = ({
type = "single",
collapsible = false,
value,
defaultValue,
onValueChange,
...props
}: AccordionProps) => {
const multiple = type === "multiple";
const handleValueChange = useCallback(
(nextValue: (unknown | null)[]) => {
if (!onValueChange) {
return;
}
const nextStringValues = nextValue.filter((item): item is string => typeof item === "string");
if (multiple) {
onValueChange(nextStringValues);
return;
}
const [firstValue] = nextStringValues;
if (firstValue !== undefined) {
onValueChange(firstValue);
return;
}
if (collapsible) {
onValueChange("");
}
},
[collapsible, multiple, onValueChange],
);
return (
<AccordionPrimitive.Root
data-slot="accordion"
defaultValue={normalizeAccordionValue(defaultValue)}
multiple={multiple}
onValueChange={onValueChange ? handleValueChange : undefined}
value={normalizeAccordionValue(value)}
{...props}
/>
);
};
const AccordionItem = ({
className,
children,
...props
}: ComponentProps<typeof AccordionPrimitive.Item>) => {
const [isOpen, setIsOpen] = useState(false);
const [canAnimate, setCanAnimate] = useState(false);
const contextValue = useMemo(
() => ({ canAnimate, isOpen, setCanAnimate, setIsOpen }),
[canAnimate, isOpen],
);
return (
<AccordionItemContext.Provider value={contextValue}>
<AccordionPrimitive.Item
className={cn("border-b last:border-b-0", className)}
data-slot="accordion-item"
{...props}
>
{children}
</AccordionPrimitive.Item>
</AccordionItemContext.Provider>
);
};
type AccordionTriggerProps = ComponentProps<typeof AccordionPrimitive.Trigger> & {
chevron?: boolean;
};
const AccordionTrigger = ({
ref,
className,
children,
chevron = true,
...props
}: AccordionTriggerProps) => {
const triggerRef = useRef<HTMLButtonElement | null>(null);
useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement);
const { isOpen, setIsOpen, canAnimate, setCanAnimate } = useAccordionItem();
useEffect(() => {
const node = triggerRef.current;
if (!node) {
return;
}
const updateState = () => {
const isExpanded =
Object.hasOwn(node.dataset, "panelOpen") || node.getAttribute("aria-expanded") === "true";
setIsOpen(isExpanded);
};
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (
mutation.attributeName === "data-panel-open" ||
mutation.attributeName === "aria-expanded"
) {
updateState();
}
}
});
observer.observe(node, {
attributeFilter: ["data-panel-open", "aria-expanded"],
attributes: true,
});
// On initial `defaultValue`, Base UI applies the panel's `data-panel-open`
// after this effect runs, and that first attribute write can land in a gap
// the observer misses — leaving the panel collapsed until the user toggles
// it. Re-read the open state across the first few frames so we reliably
// catch it whenever Base UI writes it, then enable animation.
let frameId = 0;
let frames = 0;
const syncOpenState = () => {
updateState();
frames += 1;
if (frames < 5) {
frameId = requestAnimationFrame(syncOpenState);
} else {
setCanAnimate(true);
}
};
syncOpenState();
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [setCanAnimate, setIsOpen]);
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
className={cn(
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-[color,background-color,border-color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
className,
)}
data-slot="accordion-trigger"
ref={triggerRef}
{...props}
>
{children}
{chevron ? (
// Odd box and bar sizes (11px / 1px) keep each bar's centred offset a
// whole number — (11 / 2) - (1 / 2) = 5px. At even sizes the 1.5px bar
// landed on 5.25px, straddling two pixels and rendering soft.
<div className="relative flex h-[11px] w-[11px] shrink-0 items-center justify-center">
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
className="absolute top-1/2 left-1/2 h-px w-[11px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
transition={
canAnimate ? { duration: 0.3, ease: [0.645, 0.045, 0.355, 1] } : { duration: 0 }
}
/>
<motion.div
animate={{ rotateZ: isOpen ? 90 : 0, scale: isOpen ? 0 : 1 }}
className="absolute top-1/2 left-1/2 h-[11px] w-px -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
style={{ transformOrigin: "center" }}
transition={
canAnimate ? { duration: 0.3, ease: [0.645, 0.045, 0.355, 1] } : { duration: 0 }
}
/>
</div>
) : null}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
};
type AccordionContentProps = ComponentProps<typeof AccordionPrimitive.Panel> &
HTMLMotionProps<"div"> & {
transition?: Transition;
};
const DEFAULT_ACCORDION_TRANSITION: Transition = {
damping: 22,
stiffness: 150,
type: "spring",
};
const AccordionContent = ({
className,
children,
transition = DEFAULT_ACCORDION_TRANSITION,
...props
}: AccordionContentProps) => {
const { isOpen, canAnimate } = useAccordionItem();
return (
<AccordionPrimitive.Panel keepMounted {...props} hidden={false}>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
animate={{ "--mask-stop": "100%", height: "auto", opacity: 1 }}
className="overflow-hidden"
data-slot="accordion-content"
exit={{ "--mask-stop": "0%", height: 0, opacity: 0 }}
initial={canAnimate ? { "--mask-stop": "0%", height: 0, opacity: 0 } : false}
key="accordion-content"
style={{
WebkitMaskImage:
"linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
maskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
}}
transition={canAnimate ? transition : { duration: 0 }}
>
<div className={cn("pt-0 pb-4 text-sm leading-[1.5]", className)}>{children}</div>
</motion.div>
) : null}
</AnimatePresence>
</AccordionPrimitive.Panel>
);
};
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };