fluid
uiaccordionsource: ok · hit
Accordion
Collapsible accordion with animated height transitions, spring-animated chevron, and fluid hover in grouped mode. Built on Radix UI Accordion.
npx shadcn add "https://www.fluidfunctionalism.com/r/accordion.json"free
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
ui-primitives@radix-ui/react-accordion
iconslucide-react
Registry dependencies
utilshttps://www.fluidfunctionalism.com/r/springs.jsonhttps://www.fluidfunctionalism.com/r/font-weight.jsonhttps://www.fluidfunctionalism.com/r/shape-context.jsonhttps://www.fluidfunctionalism.com/r/icon-context.jsonhttps://www.fluidfunctionalism.com/r/use-fluid-hover.jsonhttps://www.fluidfunctionalism.com/r/size-context.jsonhttps://www.fluidfunctionalism.com/r/tokens.json
Files1 · source cached 10/10/2026, 12:22:05 AM
registry/radix/accordion.tsx
"use client";
import {
useRef,
useState,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
createContext,
useContext,
forwardRef,
type ReactNode,
type HTMLAttributes,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
// SSR-safe layout effect (client components still server-render in Next).
const useIsoLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
import { cn } from "@/lib/utils";
import { useIcons } from "@/lib/icon-context";
import { spring } from "@/lib/springs";
import { fontWeights } from "@/lib/font-weight";
import { useFluidHover, useRegisterFluidHoverItem } from "@/hooks/use-fluid-hover";
import { useShape } from "@/lib/shape-context";
import { SizeProvider, useSize, type SizeVariant } from "@/lib/size-context";
import { FluidHoverHighlight } from "@/components/ui/fluid-hover-highlight";
// ─── Contexts ────────────────────────────────────────────────────────────────
interface ItemRect {
top: number;
left: number;
width: number;
height: number;
}
interface AccordionGroupContextValue {
registerItem: (index: number, element: HTMLElement | null) => void;
registerFullItem: (index: number, element: HTMLElement | null) => void;
activeIndex: number | null;
grouped: true;
remeasure: () => void;
openValues: Set<string>;
openItemRects: Map<number, ItemRect>;
}
const AccordionGroupContext =
createContext<AccordionGroupContextValue | null>(null);
function useAccordionGroup() {
return useContext(AccordionGroupContext);
}
interface AccordionItemContextValue {
index?: number;
value: string;
isOpen: boolean;
triggerRef: React.MutableRefObject<HTMLDivElement | null>;
/** Standalone items carry the group's choice themselves. */
highlight: "trigger" | "item";
}
const AccordionItemContext =
createContext<AccordionItemContextValue | null>(null);
function useAccordionItemContext() {
const ctx = useContext(AccordionItemContext);
if (!ctx)
throw new Error(
"AccordionTrigger/AccordionContent must be used within an AccordionItem"
);
return ctx;
}
// ─── AccordionGroup ──────────────────────────────────────────────────────────
type AccordionGroupSingleProps = {
type?: "single";
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
collapsible?: boolean;
};
type AccordionGroupMultipleProps = {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
type AccordionGroupProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
/** Pins the group's rows to one step of the size ladder (default 36px,
* compact 28px — see /docs/sizes). Omitted, they follow the surrounding
* SizeProvider. */
size?: SizeVariant;
/** What an open item tints. "item" paints the row and its panel as one
* block, and holds while it stays open. "trigger" scopes the fill to the
* row and shows it on hover only, leaving the panel on the page's own
* surface — the way a sidebar row highlights without colouring its
* sub-tree. @default "item" */
highlight?: "trigger" | "item";
} & (AccordionGroupSingleProps | AccordionGroupMultipleProps);
const AccordionGroup = forwardRef<HTMLDivElement, AccordionGroupProps>(
(props, ref) => {
const {
children,
highlight = "item",
type = "single",
size,
className,
...rest
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const fullItemElementsRef = useRef<Map<number, HTMLElement>>(new Map());
const [openItemRects, setOpenItemRects] = useState<Map<number, ItemRect>>(
new Map()
);
const openItemRectsRef = useRef(openItemRects);
const hover = useFluidHover(containerRef);
const {
activeIndex,
setActiveIndex,
itemRects,
handlers,
registerItem,
measureItems,
} = hover;
const registerFullItem = useCallback(
(index: number, element: HTMLElement | null) => {
if (element) {
fullItemElementsRef.current.set(index, element);
} else {
fullItemElementsRef.current.delete(index);
}
},
[]
);
const measureFullItems = useCallback(() => {
if (!containerRef.current) return;
const next = new Map<number, ItemRect>();
// Use offset* (layout coords) to match the fluid hover hook's items.
// getBoundingClientRect would return visual coords already scaled by
// any ancestor transform; once applied as CSS inside the same scaled
// container, the overlay would scale a second time.
fullItemElementsRef.current.forEach((el, idx) => {
next.set(idx, {
top: el.offsetTop,
left: el.offsetLeft,
width: el.offsetWidth,
height: el.offsetHeight,
});
});
// Skip the state update when nothing moved (mirrors the fluid hover
// hook's measureItems guard) — this runs per animation frame via
// onUpdate, and an unconditional set would invalidate the group
// context and re-render every item even on no-op remeasures.
const prev = openItemRectsRef.current;
let changed = prev.size !== next.size;
if (!changed) {
for (const [idx, r] of next) {
const p = prev.get(idx);
if (
!p ||
p.top !== r.top ||
p.left !== r.left ||
p.width !== r.width ||
p.height !== r.height
) {
changed = true;
break;
}
}
}
if (!changed) return;
openItemRectsRef.current = next;
setOpenItemRects(next);
}, []);
// Track open values for context
const [internalSingleValue, setInternalSingleValue] = useState<string>(
() => {
if (type === "single") {
const sp = props as AccordionGroupSingleProps;
return sp.defaultValue ?? "";
}
return "";
}
);
const [internalMultipleValue, setInternalMultipleValue] = useState<
string[]
>(() => {
if (type === "multiple") {
const mp = props as AccordionGroupMultipleProps;
return mp.defaultValue ?? [];
}
return [];
});
const singleOnValueChange = (props as AccordionGroupSingleProps).onValueChange;
const multipleOnValueChange = (props as AccordionGroupMultipleProps).onValueChange;
const openValuesList: string[] =
type === "multiple"
? (props as AccordionGroupMultipleProps).value ?? internalMultipleValue
: (() => {
const v =
(props as AccordionGroupSingleProps).value ?? internalSingleValue;
return v ? [v] : [];
})();
// Keyed on the serialized values so the Set (and the group context
// value below) keeps a stable identity across re-renders where the open
// values haven't actually changed.
const openValuesKey = JSON.stringify(openValuesList);
const openValues = useMemo(
() => new Set(JSON.parse(openValuesKey) as string[]),
[openValuesKey]
);
const handleSingleValueChange = useCallback(
(value: string) => {
if (singleOnValueChange) singleOnValueChange(value);
else setInternalSingleValue(value);
},
[singleOnValueChange]
);
const handleMultipleValueChange = useCallback(
(value: string[]) => {
if (multipleOnValueChange) multipleOnValueChange(value);
else setInternalMultipleValue(value);
},
[multipleOnValueChange]
);
useEffect(() => {
measureItems();
measureFullItems();
}, [measureItems, measureFullItems, children]);
// Remeasure when open values change so the first paint already
// reflects shifted trigger positions.
useEffect(() => {
measureItems();
measureFullItems();
}, [measureItems, measureFullItems, openValuesKey]);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;
// Dimming: reduce expanded BG opacity when hovering a non-expanded trigger
// An open item tints its trigger by default; "item" restores the older
// block treatment that spans the panel too. The trigger rects are the
// ones fluid hover already tracks, so this is a choice of source.
// "trigger" tints the open row only while you're on it: the panel below
// already says the item is open, so the fill goes back to being a hover
// affordance rather than a persistent state.
const expandedRects =
highlight === "item"
? openItemRects
: new Map(
[...openItemRects.keys()].flatMap((idx) => {
const rect = idx === activeIndex ? itemRects[idx] : null;
return rect ? ([[idx, rect]] as [number, ItemRect][]) : [];
})
);
const isHoveringNonOpen =
activeIndex !== null && !openItemRects.has(activeIndex);
const shape = useShape();
// Strip non-HTML props before spreading
const {
value: _value,
defaultValue: _defaultValue,
onValueChange: _onValueChange,
collapsible: _collapsible,
type: _type,
...htmlProps
} = rest as Record<string, unknown>;
// Build Radix root props
const radixProps =
type === "multiple"
? {
type: "multiple" as const,
value:
(props as AccordionGroupMultipleProps).value ??
internalMultipleValue,
onValueChange: handleMultipleValueChange,
}
: {
type: "single" as const,
collapsible:
(props as AccordionGroupSingleProps).collapsible ?? true,
value:
(props as AccordionGroupSingleProps).value ?? internalSingleValue,
onValueChange: handleSingleValueChange,
};
const remeasure = useCallback(() => {
measureItems();
measureFullItems();
}, [measureItems, measureFullItems]);
// Memoized: the group re-renders on every fluid-hover mousemove; a
// fresh context object each time would re-render every item with it.
const groupContextValue = useMemo<AccordionGroupContextValue>(
() => ({
registerItem,
registerFullItem,
activeIndex,
grouped: true,
remeasure,
openValues,
openItemRects,
}),
[
registerItem,
registerFullItem,
activeIndex,
remeasure,
openValues,
openItemRects,
]
);
const group = (
<AccordionGroupContext.Provider value={groupContextValue}>
<AccordionPrimitive.Root {...radixProps} asChild>
<div
ref={(node) => {
(
containerRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
if (typeof ref === "function") ref(node);
else if (ref)
(
ref as React.MutableRefObject<HTMLDivElement | null>
).current = node;
}}
onMouseEnter={handlers.onMouseEnter}
onMouseMove={(e) => {
// Suppress fluid hover when cursor is over an expanded
// content area (below the item's trigger). This keeps trigger
// hover scoped to the trigger row only.
const container = containerRef.current;
if (container) {
const cRect = container.getBoundingClientRect();
const layoutH = container.offsetHeight;
const visualH = cRect.height;
const scale = layoutH > 0 ? visualH / layoutH : 1;
const localY =
(e.clientY - cRect.top) / scale + container.scrollTop;
for (const [idx, full] of openItemRects) {
const trigger = itemRects[idx];
if (!trigger) continue;
const contentTop = trigger.top + trigger.height;
const contentBottom = full.top + full.height;
if (localY >= contentTop && localY <= contentBottom) {
setActiveIndex(null);
return;
}
}
}
handlers.onMouseMove(e);
}}
onMouseLeave={handlers.onMouseLeave}
onFocus={(e) => {
const indexAttr = (e.target as HTMLElement)
.closest("[data-fluid-hover-index]")
?.getAttribute("data-fluid-hover-index");
if (indexAttr != null) {
const idx = Number(indexAttr);
setActiveIndex(idx);
setFocusedIndex(
(e.target as HTMLElement).matches(":focus-visible")
? idx
: null
);
}
}}
onBlur={(e) => {
if (
containerRef.current?.contains(e.relatedTarget as Node)
)
return;
setFocusedIndex(null);
setActiveIndex(null);
}}
className={cn(
"relative flex flex-col w-72 max-w-full",
className
)}
{...(htmlProps as HTMLAttributes<HTMLDivElement>)}
>
{/* Expanded item backgrounds */}
<AnimatePresence>
{[...expandedRects.entries()].map(([idx, rect]) => (
<motion.div
key={`expanded-${idx}`}
className={`absolute ${shape.bg} bg-accent/20 dark:bg-accent/12 pointer-events-none`}
// Fade in from the item's current rect: with initial={false}
// a newly-opened item's background would pop in at full
// opacity mid-layout-shift while the previous item's bg is
// still fading out — reads as a glitch when switching items
// (especially under /demo's scaled card). Geometry still
// snaps (duration 0) so the bg hugs the animating item.
initial={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
opacity: 0,
}}
animate={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
opacity: isHoveringNonOpen ? 0.7 : 1,
}}
exit={{ opacity: 0, transition: spring.moderate.exit }}
transition={{
top: { duration: 0 },
left: { duration: 0 },
width: { duration: 0 },
height: { duration: 0 },
opacity: { duration: 0.12 },
}}
/>
))}
</AnimatePresence>
{/* Hover background */}
<FluidHoverHighlight
hover={hover}
className={shape.bg}
/>
{/* Focus ring */}
<AnimatePresence>
{focusRect && (
<motion.div
className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[color:var(--focus-ring,#6B97FF)]`}
initial={false}
animate={{
left: focusRect.left - 2,
top: focusRect.top - 2,
width: focusRect.width + 4,
height: focusRect.height + 4,
}}
exit={{ opacity: 0, transition: spring.fast.exit }}
transition={{
...spring.fast,
opacity: { duration: 0.08 },
}}
/>
)}
</AnimatePresence>
{children}
</div>
</AccordionPrimitive.Root>
</AccordionGroupContext.Provider>
);
// A size prop pins every row in the group to one ladder step.
return size ? <SizeProvider size={size}>{group}</SizeProvider> : group;
}
);
AccordionGroup.displayName = "AccordionGroup";
// ─── Accordion (Standalone) ──────────────────────────────────────────────────
interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
type?: "single" | "multiple";
collapsible?: boolean;
defaultValue?: string | string[];
value?: string | string[];
onValueChange?: ((value: string) => void) | ((value: string[]) => void);
/** Pins the accordion's rows to one step of the size ladder (default 36px,
* compact 28px — see /docs/sizes). Omitted, they follow the surrounding
* SizeProvider. */
size?: SizeVariant;
}
const Accordion = forwardRef<HTMLDivElement, AccordionProps>(
(
{
children,
type = "single",
collapsible = true,
defaultValue,
value,
onValueChange,
size,
className,
...props
},
ref
) => {
// Track open values for AccordionItemContext
const [internalSingleValue, setInternalSingleValue] = useState<string>(
() => {
if (type === "single") {
return (defaultValue as string) ?? "";
}
return "";
}
);
const [internalMultipleValue, setInternalMultipleValue] = useState<
string[]
>(() => {
if (type === "multiple") {
return (defaultValue as string[]) ?? [];
}
return [];
});
const openValues = new Set<string>(
type === "multiple"
? (value as string[] | undefined) ?? internalMultipleValue
: (() => {
const v = (value as string | undefined) ?? internalSingleValue;
return v ? [v] : [];
})()
);
const handleSingleChange = useCallback(
(v: string) => {
if (onValueChange) (onValueChange as (v: string) => void)(v);
else setInternalSingleValue(v);
},
[onValueChange]
);
const handleMultipleChange = useCallback(
(v: string[]) => {
if (onValueChange) (onValueChange as (v: string[]) => void)(v);
else setInternalMultipleValue(v);
},
[onValueChange]
);
// `value` is always defined here (internal state is seeded from
// `defaultValue` above), so the Radix root is permanently controlled and
// forwarding `defaultValue` would be dead.
const radixProps =
type === "multiple"
? {
type: "multiple" as const,
value: (value as string[] | undefined) ?? internalMultipleValue,
onValueChange: handleMultipleChange,
}
: {
type: "single" as const,
collapsible,
value: (value as string | undefined) ?? internalSingleValue,
onValueChange: handleSingleChange,
};
const root = (
<AccordionPrimitive.Root {...radixProps} asChild>
<div
ref={ref}
className={cn(
"w-72 max-w-full flex flex-col",
className
)}
{...props}
>
<StandaloneOpenContext.Provider value={openValues}>
{children}
</StandaloneOpenContext.Provider>
</div>
</AccordionPrimitive.Root>
);
// A size prop pins every row to one ladder step.
return size ? <SizeProvider size={size}>{root}</SizeProvider> : root;
}
);
Accordion.displayName = "Accordion";
// Standalone context to provide open values without AccordionGroup
const StandaloneOpenContext = createContext<Set<string>>(new Set());
// ─── AccordionItem ───────────────────────────────────────────────────────────
interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
value: string;
index?: number;
disabled?: boolean;
/** Standalone equivalent of AccordionGroup's prop: what an open item
* tints. Ignored inside a group, which decides for all its rows.
* @default "item" */
highlight?: "trigger" | "item";
children: ReactNode;
}
const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
({ value, index, disabled, highlight = "item", children, className, ...props }, ref) => {
const internalRef = useRef<HTMLDivElement>(null);
const groupCtx = useAccordionGroup();
const standaloneOpen = useContext(StandaloneOpenContext);
const shape = useShape();
const isOpen = groupCtx?.grouped
? groupCtx.openValues.has(value)
: standaloneOpen.has(value);
const triggerRef = useRef<HTMLDivElement>(null);
// Register trigger element (not full item) for fluid hover
useRegisterFluidHoverItem(
groupCtx?.grouped ? groupCtx.registerItem : undefined,
index,
triggerRef
);
// Register full item element for expanded background measurement
useEffect(() => {
if (groupCtx?.grouped && index !== undefined) {
if (isOpen) {
groupCtx.registerFullItem(index, internalRef.current);
} else {
groupCtx.registerFullItem(index, null);
}
return () => groupCtx.registerFullItem(index, null);
}
}, [index, groupCtx, isOpen]);
return (
<AccordionItemContext.Provider value={{ index, value, isOpen, triggerRef, highlight }}>
<AccordionPrimitive.Item
ref={(node) => {
(
internalRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
if (typeof ref === "function") ref(node);
else if (ref)
(
ref as React.MutableRefObject<HTMLDivElement | null>
).current = node;
}}
value={value}
disabled={disabled}
data-fluid-hover-index={index}
className={cn(!groupCtx?.grouped && "relative", className)}
{...props}
>
{/* Standalone expanded background. Under the default "trigger"
choice the tint lives inside AccordionTrigger, where it covers
the row and not the panel below it. */}
{!groupCtx?.grouped && highlight === "item" && (
<AnimatePresence>
{isOpen && (
<motion.div
className={`absolute inset-0 ${shape.bg} bg-accent/20 dark:bg-accent/12 pointer-events-none`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: spring.moderate.exit }}
transition={{ duration: 0.12 }}
/>
)}
</AnimatePresence>
)}
{children}
</AccordionPrimitive.Item>
</AccordionItemContext.Provider>
);
}
);
AccordionItem.displayName = "AccordionItem";
// ─── AccordionTrigger ────────────────────────────────────────────────────────
interface AccordionTriggerProps
extends HTMLAttributes<HTMLButtonElement> {
children: ReactNode;
}
const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ children, className, ...props }, ref) => {
const icons = useIcons();
const ChevronRight = icons["chevron-right"];
const groupCtx = useAccordionGroup();
const { index, isOpen, triggerRef, highlight } = useAccordionItemContext();
const shape = useShape();
const sizeClasses = useSize();
const [isHovered, setIsHovered] = useState(false);
const isActive = groupCtx?.grouped
? groupCtx.activeIndex === index
: isHovered;
const triggerContent = (
<AccordionPrimitive.Header asChild>
<div>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
`relative z-10 flex items-center ${sizeClasses.gap} ${shape.item} ${sizeClasses.px} ${sizeClasses.variant === "compact" ? "py-1" : "py-2"} w-full cursor-pointer outline-none select-none`,
!groupCtx?.grouped &&
"focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)] focus-visible:ring-offset-0",
className
)}
{...(props as React.ComponentProps<typeof AccordionPrimitive.Trigger>)}
>
{/* Label with dual-layer text */}
<span className={cn("inline-grid flex-1 text-left", sizeClasses.text)}>
<span
className="col-start-1 row-start-1 invisible"
style={{ fontVariationSettings: fontWeights.semibold }}
aria-hidden="true"
>
{children}
</span>
<span
className={cn(
"col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80",
isOpen || isActive
? "text-foreground"
: "text-muted-foreground"
)}
style={{
fontVariationSettings:
isOpen ? fontWeights.semibold : fontWeights.normal,
}}
>
{children}
</span>
</span>
{/* Chevron — right when collapsed, rotates 90° down when expanded */}
<motion.span
className="shrink-0 inline-flex items-center justify-center"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={spring.fast}
>
<ChevronRight
size={sizeClasses.icon}
strokeWidth={isOpen || isActive ? 2 : 1.5}
className={cn(
"transition-[color,stroke-width] duration-80",
isOpen || isActive
? "text-foreground"
: "text-muted-foreground"
)}
/>
</motion.span>
</AccordionPrimitive.Trigger>
</div>
</AccordionPrimitive.Header>
);
// In grouped mode, wrap in a div for fluid hover registration
if (groupCtx?.grouped) {
return <div ref={triggerRef}>{triggerContent}</div>;
}
// Standalone mode: local hover with animated BG
return (
<div
className="relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Open tint, scoped to this row: the panel below keeps the page's
own surface, the way a sidebar row highlights without colouring
its sub-tree. */}
<AnimatePresence>
{isOpen && highlight === "trigger" && isHovered && (
<motion.div
className={`absolute inset-0 ${shape.bg} bg-accent/20 dark:bg-accent/12 pointer-events-none`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
// The expanded tint rides the moderate tier, like the grouped
// one — it marks a state, where the hover fill below tracks the
// pointer and stays fast.
exit={{ opacity: 0, transition: spring.moderate.exit }}
transition={{ duration: 0.12 }}
/>
)}
</AnimatePresence>
<AnimatePresence>
{isHovered && (
<motion.div
className={`absolute inset-0 ${shape.bg} bg-hover pointer-events-none`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: spring.fast.exit }}
transition={{ duration: 0.08 }}
/>
)}
</AnimatePresence>
{triggerContent}
</div>
);
}
);
AccordionTrigger.displayName = "AccordionTrigger";
// ─── AccordionContent ────────────────────────────────────────────────────────
interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
({ children, className, ...props }, ref) => {
const groupCtx = useAccordionGroup();
const { isOpen } = useAccordionItemContext();
const sizeClasses = useSize();
// Read here rather than relying on a MotionConfig the consumer may not
// have: height is a positional value, so framer would otherwise animate
// it for a reduced-motion user in any app that installs this component.
const reduceMotion = useReducedMotion() ?? false;
// The open height is animated to a self-measured LAYOUT pixel value, not
// `height: "auto"`: framer resolves an "auto" target by measuring the
// element's *visual* (transformed) size, so under a scaled ancestor
// (e.g. /demo's 1.7x card) the animation overshoots to scale× the real
// height and snaps back when the final "auto" lands — a visible height
// reduction at the end of every open. offsetHeight and ResizeObserver
// are transform-immune.
const innerRef = useRef<HTMLDivElement | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const [contentHeight, setContentHeight] = useState<number | null>(null);
// Height springs only when THIS panel toggles. When contentHeight
// changes underneath it instead — anything collapsible nested inside
// the panel, another accordion included — it must snap: a spring
// re-targeted every frame chases the child's own animation, lands
// after it, and drags everything below the item along late. Same rule
// as SidebarGroup / SidebarMenuSub; see motion-guidelines.md.
// Items open at mount render `initial: "auto"` and receive their first
// pixel target a commit later. That hand-off is not a toggle, so it
// snaps too — it must: framer would measure a spring's numeric start
// visually (scaled) and play a shrink. Items that open later spring.
const [prevOpen, setPrevOpen] = useState(isOpen);
const [toggling, setToggling] = useState(false);
if (prevOpen !== isOpen) {
setPrevOpen(isOpen);
setToggling(true);
}
const measureRef = useCallback((el: HTMLDivElement | null) => {
roRef.current?.disconnect();
roRef.current = null;
innerRef.current = el;
if (!el) return;
if (el.offsetHeight > 0) setContentHeight(el.offsetHeight);
const ro = new ResizeObserver(() => {
// Ignore the 0 that fires while the panel is display:none.
if (el.offsetHeight > 0) setContentHeight(el.offsetHeight);
});
ro.observe(el);
roRef.current = ro;
}, []);
// Re-measure synchronously (pre-paint) when opening, so the spring's
// target is the fresh layout height from its first frame.
useIsoLayoutEffect(() => {
if (isOpen && innerRef.current && innerRef.current.offsetHeight > 0) {
setContentHeight(innerRef.current.offsetHeight);
}
}, [isOpen]);
// Content stays mounted so it can be measured; fully-closed panels are
// display:none (hidden) once the exit finishes, keeping them out of the
// accessibility tree without cutting the animation short.
const [exitComplete, setExitComplete] = useState(!isOpen);
if (isOpen && exitComplete) {
// Reset during render so the panel is un-hidden before the opening
// animation's first paint.
setExitComplete(false);
}
return (
<AccordionPrimitive.Content forceMount asChild {...props}>
<motion.div
ref={ref}
hidden={!isOpen && exitComplete}
className={cn("overflow-hidden", className)}
initial={{ height: isOpen ? "auto" : 0 }}
animate={{ height: isOpen ? contentHeight ?? 0 : 0, opacity: isOpen ? 1 : 0 }}
// spring.fast lands with the trigger's chevron, and its bounce: 0
// keeps pure height from overshooting its content. A close is a
// decision already made, so it takes the quicker exit tier — the
// target flip has no `exit` prop to carry it. Opacity runs ahead of
// the height on its own timing: the body dissolves rather than being
// sliced by the clip edge, which is what stops the rows below
// reading as shoved.
transition={
reduceMotion || !toggling
? { duration: 0 }
: isOpen
? { ...spring.fast, opacity: { duration: 0.06 } }
: { ...spring.fast.exit, opacity: { duration: 0.04 } }
}
onUpdate={() => {
groupCtx?.remeasure();
}}
onAnimationComplete={() => {
setToggling(false);
groupCtx?.remeasure();
if (!isOpen) setExitComplete(true);
}}
>
<div
ref={measureRef}
className={cn(
"pt-1 text-muted-foreground",
sizeClasses.px,
sizeClasses.text,
sizeClasses.variant === "compact" ? "pb-2.5" : "pb-3"
)}
>
{children}
</div>
</motion.div>
</AccordionPrimitive.Content>
);
}
);
AccordionContent.displayName = "AccordionContent";
// ─── Exports ─────────────────────────────────────────────────────────────────
export {
Accordion,
AccordionGroup,
AccordionItem,
AccordionTrigger,
AccordionContent,
};
export default Accordion;