soralabs
uiaccordionsource: ok · refreshed
Accordion
An animated FAQ accordion with expandable panels, powered by Motion.
npx shadcn add "https://ui.soralabs.studio/r/accordion.json"free
Where it goes
faq
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
utils@soralabs/hooks-use-auto-height
Files1 · source cached 10/8/2026, 6:12:02 AM
registry/primitives/disclosure/accordion/index.tsx
"use client";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import {
type AnimationSequence,
stagger,
useAnimate,
useReducedMotion,
} from "motion/react";
import {
type ComponentPropsWithoutRef,
createContext,
type ReactNode,
type RefObject,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useAutoHeight } from "@/hooks/use-auto-height";
const EXPO_IN_OUT = [0.87, 0, 0.13, 1] as const;
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
const POWER2_IN_OUT = [0.45, 0, 0.55, 1] as const;
type IconMode = "rotate" | "fade" | "both";
type DisabledBreakpoint = "mobile" | "tablet" | "desktop";
type AccordionEase = readonly [number, number, number, number];
const DISABLE_MEDIA: Record<DisabledBreakpoint, string> = {
mobile: "(max-width: 479px)",
tablet: "(max-width: 991px)",
desktop: "(min-width: 992px)",
};
interface AccordionContextValue {
allowMultiple: boolean;
animationsPaused: boolean;
defaultDuration: number;
defaultEase: AccordionEase;
defaultIconRotation: number;
disabledOn?: DisabledBreakpoint[];
iconMode: IconMode;
notifyItemOpen: (id: string) => void;
registerItem: (id: string, close: () => void) => void;
unregisterItem: (id: string) => void;
}
interface AccordionItemContextValue {
contentId: string;
contentPanelRef: RefObject<HTMLElement | null>;
iconRef: RefObject<SVGSVGElement | null>;
isDisabled: boolean;
isOpen: boolean;
setPanelHeight: (height: number) => void;
toggle: () => void;
triggerId: string;
verticalBarRef: RefObject<SVGPathElement | null>;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
const AccordionItemContext = createContext<AccordionItemContextValue | null>(
null
);
function useAccordionContext() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error("Accordion components must be used within <Accordion>.");
}
return context;
}
function useAccordionItemContext() {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error(
"Accordion subcomponents must be used within <AccordionItem>."
);
}
return context;
}
function useDisabledOn(disabledOn?: DisabledBreakpoint[]) {
const [disabled, setDisabled] = useState(false);
useEffect(() => {
if (!disabledOn?.length) {
setDisabled(false);
return;
}
const mediaQueries = disabledOn.map((breakpoint) =>
window.matchMedia(DISABLE_MEDIA[breakpoint])
);
const update = () => {
setDisabled(mediaQueries.some((mediaQuery) => mediaQuery.matches));
};
update();
for (const mediaQuery of mediaQueries) {
mediaQuery.addEventListener("change", update);
}
return () => {
for (const mediaQuery of mediaQueries) {
mediaQuery.removeEventListener("change", update);
}
};
}, [disabledOn]);
return disabled;
}
const accordionVariants = cva("flex w-full flex-col");
const accordionItemVariants = cva("border-border border-b last:border-b-0");
const accordionTriggerVariants = cva([
"flex w-full items-center justify-between gap-4 border-0 bg-transparent py-5 text-left",
"cursor-pointer text-muted-foreground transition-colors duration-[400ms] ease-[cubic-bezier(0.16,1,0.3,1)]",
"will-change-transform [transform:translateZ(0)] hover:text-foreground",
"focus:outline-none focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-[#fd551d] focus-visible:outline-offset-4",
"data-[state=open]:text-foreground",
]);
const accordionTitleVariants = cva(
"m-0 font-medium text-base text-inherit leading-[1.4]"
);
const accordionContentVariants = cva(
"overflow-hidden will-change-[height] [transform:translateZ(0)]"
);
const accordionTextVariants = cva(
"m-0 pb-5 text-muted-foreground text-sm leading-[1.6]"
);
export interface AccordionItemData {
content: ReactNode;
defaultOpen?: boolean;
/** Reveal panel text line by line when this item opens. */
enableStagger?: boolean;
id?: string;
title: string;
}
export interface AccordionProps
extends Omit<ComponentPropsWithoutRef<"div">, "children">,
VariantProps<typeof accordionVariants> {
allowMultiple?: boolean;
children?: ReactNode;
/**
* Disable accordion animations on matching breakpoints.
* Responsive media queries to disable transitions on mobile, tablet, or desktop.
*/
disabledOn?: DisabledBreakpoint[];
duration?: number;
ease?: AccordionEase;
iconMode?: IconMode;
iconRotation?: number;
items?: AccordionItemData[];
}
export interface AccordionItemProps
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
defaultOpen?: boolean;
delay?: number;
disabledOn?: DisabledBreakpoint[];
duration?: number;
ease?: AccordionEase;
/**
* Reveal panel text line by line when the item opens.
* @default false
*/
enableStagger?: boolean;
iconRotation?: number;
onClose?: () => void;
onOpen?: () => void;
/**
* Delay between each staggered line, in seconds.
* @default 0.15
*/
staggerDelay?: number;
/**
* Duration of each line reveal, in seconds.
* @default 0.6
*/
staggerDuration?: number;
/**
* Easing curve for the line reveal.
* @default expo.out
*/
staggerEase?: AccordionEase;
/**
* Wait before the line reveal starts after opening, in milliseconds.
* @default 200
*/
staggerStartDelay?: number;
/**
* Initial vertical offset of each line, as a percentage of its height.
* @default 110
*/
staggerYPercent?: number;
}
export interface AccordionTriggerProps
extends ComponentPropsWithoutRef<"button"> {
children: ReactNode;
hideIcon?: boolean;
}
export interface AccordionContentProps
extends ComponentPropsWithoutRef<"section"> {
children: ReactNode;
}
export interface AccordionTextProps
extends Omit<ComponentPropsWithoutRef<"p">, "children"> {
children: ReactNode;
}
function renderAccordionItemContent(content: ReactNode) {
if (typeof content === "string") {
return <AccordionText>{content}</AccordionText>;
}
return content;
}
interface SplitLinesResult {
lines: HTMLElement[];
revert: () => void;
}
const WHITESPACE_REGEX = /\s+/;
/**
* Lightweight SplitText alternative: wraps each word to measure natural line
* breaks, then rebuilds the element as masked line blocks ready to animate.
* Nested markup is flattened to plain text while the split is active.
*/
function splitElementIntoLines(element: HTMLElement): SplitLinesResult {
const originalNodes = Array.from(element.childNodes);
const words = (element.textContent ?? "")
.split(WHITESPACE_REGEX)
.filter(Boolean);
element.replaceChildren();
const wordSpans: HTMLSpanElement[] = [];
for (const word of words) {
const span = document.createElement("span");
span.style.display = "inline-block";
span.textContent = word;
element.append(span, document.createTextNode(" "));
wordSpans.push(span);
}
const lineGroups: string[][] = [];
let lastTop: number | null = null;
for (const span of wordSpans) {
const top = span.offsetTop;
if (lastTop === null || Math.abs(top - lastTop) > 1) {
lineGroups.push([]);
lastTop = top;
}
lineGroups.at(-1)?.push(span.textContent ?? "");
}
element.replaceChildren();
const lines: HTMLElement[] = [];
for (const group of lineGroups) {
const mask = document.createElement("div");
mask.style.display = "block";
mask.style.overflow = "hidden";
const line = document.createElement("div");
line.style.display = "block";
line.style.willChange = "transform";
line.textContent = group.join(" ");
mask.appendChild(line);
element.appendChild(mask);
lines.push(line);
}
return {
lines,
revert: () => {
element.replaceChildren(...originalNodes);
},
};
}
function Accordion({
allowMultiple = false,
children,
className,
disabledOn,
duration = 0.8,
ease = EXPO_IN_OUT,
iconMode = "both",
iconRotation = -180,
items,
...props
}: AccordionProps) {
const itemsRef = useRef(new Map<string, () => void>());
const [animationsPaused, setAnimationsPaused] = useState(false);
useEffect(() => {
const handleVisibilityChange = () => {
setAnimationsPaused(document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
const registerItem = useCallback((id: string, close: () => void) => {
itemsRef.current.set(id, close);
}, []);
const unregisterItem = useCallback((id: string) => {
itemsRef.current.delete(id);
}, []);
const notifyItemOpen = useCallback(
(id: string) => {
if (allowMultiple) {
return;
}
for (const [itemId, close] of itemsRef.current.entries()) {
if (itemId !== id) {
close();
}
}
},
[allowMultiple]
);
return (
<AccordionContext.Provider
value={{
allowMultiple,
animationsPaused,
defaultDuration: duration,
defaultEase: ease,
defaultIconRotation: iconRotation,
disabledOn,
iconMode,
notifyItemOpen,
registerItem,
unregisterItem,
}}
>
<div
className={cn(accordionVariants(), className)}
data-anm-accordion=""
data-anm-allow-multiple={allowMultiple ? "true" : "false"}
{...props}
>
{items?.map((item, index) => (
<AccordionItem
defaultOpen={item.defaultOpen}
enableStagger={item.enableStagger}
key={item.id ?? item.title ?? index}
>
<AccordionTrigger>{item.title}</AccordionTrigger>
<AccordionContent>
{renderAccordionItemContent(item.content)}
</AccordionContent>
</AccordionItem>
))}
{children}
</div>
</AccordionContext.Provider>
);
}
function AccordionItem({
children,
className,
defaultOpen = false,
delay = 0,
disabledOn: itemDisabledOn,
duration: itemDuration,
ease: itemEase,
enableStagger = false,
iconRotation: itemIconRotation,
onClose,
onOpen,
staggerDelay = 0.15,
staggerDuration = 0.6,
staggerEase = EXPO_OUT,
staggerStartDelay = 200,
staggerYPercent = 110,
...props
}: AccordionItemProps) {
const {
animationsPaused,
defaultDuration,
defaultEase,
defaultIconRotation,
disabledOn: sectionDisabledOn,
iconMode,
notifyItemOpen,
registerItem,
unregisterItem,
} = useAccordionContext();
const itemId = useId();
const triggerId = `${itemId}-trigger`;
const contentId = `${itemId}-content`;
const itemRef = useRef<HTMLDivElement>(null);
const contentPanelRef = useRef<HTMLElement>(null);
const iconRef = useRef<SVGSVGElement>(null);
const verticalBarRef = useRef<SVGPathElement>(null);
const [, animate] = useAnimate();
const animationRef = useRef<{ stop: () => void } | null>(null);
const hasAppliedInitialOpen = useRef(false);
const shouldReduceMotion = useReducedMotion();
const splitRevertsRef = useRef<(() => void)[]>([]);
const staggerAnimationRef = useRef<{ stop: () => void } | null>(null);
const staggerTimeoutRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [panelHeight, setPanelHeight] = useState(0);
const isOpenRef = useRef(isOpen);
useEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);
const sectionAnimationsDisabled = useDisabledOn(sectionDisabledOn);
const itemAnimationsDisabled = useDisabledOn(itemDisabledOn);
const animationsDisabled =
sectionAnimationsDisabled || itemAnimationsDisabled;
const duration = itemDuration ?? defaultDuration;
const ease = itemEase ?? defaultEase;
const iconRotation = itemIconRotation ?? defaultIconRotation;
const close = useCallback(() => {
isOpenRef.current = false;
setIsOpen(false);
}, []);
useEffect(() => {
registerItem(itemId, close);
return () => unregisterItem(itemId);
}, [close, itemId, registerItem, unregisterItem]);
const dispatchItemEvent = useCallback(
(type: "anm-accordion-open" | "anm-accordion-close") => {
const node = itemRef.current;
const content = contentPanelRef.current;
if (!node) {
return;
}
node.dispatchEvent(
new CustomEvent(type, {
bubbles: true,
detail: { item: node, content },
})
);
},
[]
);
const toggle = useCallback(() => {
if (isOpenRef.current) {
onClose?.();
dispatchItemEvent("anm-accordion-close");
close();
return;
}
notifyItemOpen(itemId);
onOpen?.();
dispatchItemEvent("anm-accordion-open");
isOpenRef.current = true;
setIsOpen(true);
}, [close, dispatchItemEvent, itemId, notifyItemOpen, onClose, onOpen]);
const clearIconInlineStyles = useCallback(() => {
iconRef.current?.style.removeProperty("transform");
verticalBarRef.current?.style.removeProperty("opacity");
}, []);
const applyStaticPanelState = useCallback(
(open: boolean, height: number) => {
const panel = contentPanelRef.current;
const icon = iconRef.current;
const vertical = verticalBarRef.current;
if (!panel) {
return;
}
panel.style.height = open ? `${height}px` : "0px";
if (icon) {
const rotateIcon = iconMode === "rotate" || iconMode === "both";
icon.style.transform =
rotateIcon && open ? `rotate(${iconRotation}deg)` : "rotate(0deg)";
}
if (vertical && (iconMode === "fade" || iconMode === "both")) {
vertical.style.opacity = open ? "0" : "1";
}
},
[iconMode, iconRotation]
);
const toInstantSequence = useCallback(
(sequence: AnimationSequence): AnimationSequence =>
sequence.map((segment) => {
const [element, keyframes, options] = segment as [
Element,
Record<string, unknown>,
{ at?: number; duration?: number; ease?: AccordionEase },
];
return [element, keyframes, { ...options, duration: 0, at: 0 }];
}) as AnimationSequence,
[]
);
const buildPanelSequence = useCallback(
(open: boolean): AnimationSequence | null => {
const panel = contentPanelRef.current;
const icon = iconRef.current;
const vertical = verticalBarRef.current;
if (!panel) {
return null;
}
const rotateIcon = iconMode === "rotate" || iconMode === "both";
const fadeVertical =
(iconMode === "fade" || iconMode === "both") && vertical;
if (open) {
const openSequence: AnimationSequence = [
[
panel,
{ height: `${panelHeight}px` },
{ duration, ease, at: delay },
],
];
if (rotateIcon && icon) {
openSequence.push([
icon,
{ rotate: iconRotation },
{ duration, ease, at: delay },
]);
}
if (fadeVertical) {
openSequence.push([
vertical,
{ opacity: 0 },
{
duration: duration * 0.5,
ease: POWER2_IN_OUT,
at: delay + duration * 0.25,
},
]);
}
return openSequence;
}
const closeSequence: AnimationSequence = [];
if (fadeVertical) {
closeSequence.push([
vertical,
{ opacity: 1 },
{ duration: duration * 0.5, ease: POWER2_IN_OUT, at: delay },
]);
}
if (rotateIcon && icon) {
closeSequence.push([
icon,
{ rotate: 0 },
{ duration, ease, at: delay },
]);
}
closeSequence.push([panel, { height: 0 }, { duration, ease, at: delay }]);
return closeSequence;
},
[delay, duration, ease, iconMode, iconRotation, panelHeight]
);
useEffect(() => {
animationRef.current?.stop();
if (animationsDisabled || shouldReduceMotion) {
applyStaticPanelState(isOpen, panelHeight);
return;
}
if (animationsPaused) {
return;
}
if (defaultOpen && isOpen && !hasAppliedInitialOpen.current) {
if (panelHeight > 0) {
hasAppliedInitialOpen.current = true;
const openSequence = buildPanelSequence(true);
if (openSequence) {
clearIconInlineStyles();
animationRef.current = animate(toInstantSequence(openSequence));
}
}
return;
}
if (isOpen && panelHeight === 0) {
return;
}
const sequence = buildPanelSequence(isOpen);
if (!sequence) {
return;
}
clearIconInlineStyles();
animationRef.current = animate(sequence);
}, [
animate,
animationsPaused,
applyStaticPanelState,
buildPanelSequence,
clearIconInlineStyles,
defaultOpen,
animationsDisabled,
isOpen,
panelHeight,
shouldReduceMotion,
toInstantSequence,
]);
const resetTextStagger = useCallback(() => {
if (staggerTimeoutRef.current !== null) {
window.clearTimeout(staggerTimeoutRef.current);
staggerTimeoutRef.current = null;
}
staggerAnimationRef.current?.stop();
staggerAnimationRef.current = null;
for (const revert of splitRevertsRef.current) {
revert();
}
splitRevertsRef.current = [];
}, []);
const animateTextStagger = useCallback(() => {
const panel = contentPanelRef.current;
if (!panel || splitRevertsRef.current.length > 0) {
return;
}
const targets = panel.querySelectorAll<HTMLElement>("p, .accordion_text");
const lines: HTMLElement[] = [];
for (const target of targets) {
const split = splitElementIntoLines(target);
splitRevertsRef.current.push(split.revert);
lines.push(...split.lines);
}
if (lines.length === 0) {
return;
}
for (const line of lines) {
line.style.transform = `translateY(${staggerYPercent}%)`;
}
staggerAnimationRef.current = animate(
lines,
{ transform: "translateY(0%)" },
{
delay: stagger(staggerDelay),
duration: staggerDuration,
ease: staggerEase,
}
);
}, [animate, staggerDelay, staggerDuration, staggerEase, staggerYPercent]);
useEffect(() => {
if (!enableStagger) {
return;
}
if (!isOpen || animationsDisabled || shouldReduceMotion) {
resetTextStagger();
return;
}
staggerTimeoutRef.current = window.setTimeout(() => {
staggerTimeoutRef.current = null;
document.fonts.ready.then(() => {
if (isOpenRef.current) {
animateTextStagger();
}
});
}, staggerStartDelay);
return () => {
if (staggerTimeoutRef.current !== null) {
window.clearTimeout(staggerTimeoutRef.current);
staggerTimeoutRef.current = null;
}
};
}, [
animateTextStagger,
animationsDisabled,
enableStagger,
isOpen,
resetTextStagger,
shouldReduceMotion,
staggerStartDelay,
]);
useEffect(() => resetTextStagger, [resetTextStagger]);
return (
<AccordionItemContext.Provider
value={{
contentId,
contentPanelRef,
iconRef,
isDisabled: animationsDisabled,
isOpen,
setPanelHeight,
toggle,
triggerId,
verticalBarRef,
}}
>
<div
className={cn(accordionItemVariants(), className)}
data-anm-accordion-item
data-state={isOpen ? "open" : "closed"}
ref={itemRef}
{...props}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
function AccordionTrigger({
children,
className,
disabled,
hideIcon = false,
onClick,
...props
}: AccordionTriggerProps) {
const {
contentId,
iconRef,
isDisabled,
isOpen,
toggle,
triggerId,
verticalBarRef,
} = useAccordionItemContext();
const isTriggerDisabled = Boolean(disabled || isDisabled);
return (
<button
{...props}
aria-controls={contentId}
aria-expanded={isOpen}
className={cn(accordionTriggerVariants(), className)}
data-anm-accordion-trigger
data-state={isOpen ? "open" : "closed"}
disabled={isTriggerDisabled}
id={triggerId}
onClick={(event) => {
onClick?.(event);
if (!(event.defaultPrevented || isTriggerDisabled)) {
toggle();
}
}}
type="button"
>
<span className={accordionTitleVariants()}>{children}</span>
{hideIcon ? null : (
<svg
aria-hidden="true"
className="size-4 shrink-0 text-current will-change-transform [transform:translateZ(0)]"
data-anm-accordion-icon
fill="none"
ref={iconRef}
viewBox="0 0 16 16"
>
<path
d="M8 1V15"
data-anm-accordion-icon-vertical
ref={verticalBarRef}
stroke="currentColor"
strokeLinecap="round"
strokeWidth={2}
/>
<path
d="M1 8H15"
data-anm-accordion-icon-horizontal
stroke="currentColor"
strokeLinecap="round"
strokeWidth={2}
/>
</svg>
)}
</button>
);
}
function AccordionContent({
children,
className,
style,
...props
}: AccordionContentProps) {
const { contentId, contentPanelRef, isOpen, setPanelHeight, triggerId } =
useAccordionItemContext();
const { ref, height } = useAutoHeight<HTMLDivElement>([children, isOpen]);
useEffect(() => {
setPanelHeight(height);
}, [height, setPanelHeight]);
return (
<section
{...props}
aria-hidden={!isOpen}
aria-labelledby={triggerId}
className={cn(accordionContentVariants(), className)}
data-anm-accordion-content=""
id={contentId}
ref={contentPanelRef}
style={{ ...style, height: 0 }}
>
<div ref={ref}>{children}</div>
</section>
);
}
function AccordionText({ children, className, ...props }: AccordionTextProps) {
return (
<p
className={cn(accordionTextVariants(), "accordion_text", className)}
{...props}
>
{children}
</p>
);
}
export type { AccordionEase, DisabledBreakpoint, IconMode };
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionText,
AccordionTrigger,
accordionContentVariants,
accordionItemVariants,
accordionTextVariants,
accordionTitleVariants,
accordionTriggerVariants,
accordionVariants,
};