tetra-ui
uiaccordionsource: ok · hit
Accordion
A vertically stacked set of interactive headings that reveal associated content sections.
npx shadcn add "https://tetra-ui.com/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 · lightreact-native-reanimated
Registry dependencies
@tetra-ui/icons@tetra-ui/slot
Files1 · source cached 10/9/2026, 4:14:15 PM
ui/accordion.tsx
import type { ComponentPropsWithRef, ComponentRef, ReactNode } from "react";
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import {
type GestureResponderEvent,
Pressable,
View,
type ViewProps,
} from "react-native";
import Animated, {
FadeInDown,
FadeOutUp,
LinearTransition,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { cn, mergeRefs } from "@/lib/utils";
import { ChevronDownIcon } from "@/components/ui/icons";
import { Slot } from "@/components/ui/slot";
// Constants
const CONTENT_ENTER = FadeInDown.springify()
.damping(28)
.stiffness(340)
.mass(0.55);
const CONTENT_EXIT = FadeOutUp.duration(120).damping(32).stiffness(380);
const ITEM_LAYOUT = LinearTransition.springify()
.damping(28)
.stiffness(340)
.mass(0.6);
const ICON_SPRING = { damping: 26, mass: 0.5, stiffness: 320 };
// Types
type AccordionType = "single" | "multiple";
type AccordionItemLayout =
| "open"
| "closed-only"
| "closed-start"
| "closed-middle"
| "closed-end";
const ACCORDION_ITEM_LAYOUT_CLASSNAME = {
"closed-end": "my-0 rounded-b-2xl",
"closed-middle": "my-0 rounded-none",
"closed-only": "my-0 rounded-2xl",
"closed-start": "my-0 rounded-t-2xl",
open: "my-2 rounded-2xl",
} as const satisfies Record<AccordionItemLayout, string>;
const AnimatedView = Animated.createAnimatedComponent(View);
type AccordionRootContextValue = {
type: AccordionType;
collapsible: boolean;
itemLayouts: Record<string, AccordionItemLayout>;
/** single: one value; multiple: open value set */
isOpen: (value: string) => boolean;
toggle: (value: string) => void;
};
type AccordionItemContextValue = {
value: string;
isOpen: boolean;
contentId: string;
triggerId: string;
};
const AccordionRootContext = createContext<AccordionRootContextValue | null>(
null
);
const AccordionItemContext = createContext<AccordionItemContextValue | null>(
null
);
const useAccordionRoot = () => {
const ctx = useContext(AccordionRootContext);
if (!ctx) {
throw new Error("Accordion components must be used within Accordion");
}
return ctx;
};
const useAccordionItem = () => {
const ctx = useContext(AccordionItemContext);
if (!ctx) {
throw new Error(
"AccordionItem, AccordionTrigger, AccordionContent, and AccordionIndicator must be used within AccordionItem"
);
}
return ctx;
};
const getAccordionItemValues = (children: ReactNode): string[] => {
const values: string[] = [];
Children.forEach(children, (child) => {
if (
isValidElement<{ value?: string }>(child) &&
typeof child.props.value === "string"
) {
values.push(child.props.value);
}
});
return values;
};
const getClosedItemLayout = (
index: number,
itemValues: string[],
isItemOpen: (value: string) => boolean
): AccordionItemLayout => {
const prevOpen = index === 0 || isItemOpen(itemValues[index - 1] ?? "");
const nextOpen =
index === itemValues.length - 1 || isItemOpen(itemValues[index + 1] ?? "");
if (prevOpen && nextOpen) {
return "closed-only";
}
if (prevOpen) {
return "closed-start";
}
if (nextOpen) {
return "closed-end";
}
return "closed-middle";
};
export type AccordionProps = {
type?: AccordionType;
/** When `type` is `single`, allow closing the open item so none are open */
collapsible?: boolean;
/** Controlled open value(s) */
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[] | undefined) => void;
children: ReactNode;
};
export const Accordion = ({
type = "single",
collapsible = false,
value: valueProp,
defaultValue,
onValueChange,
children,
}: AccordionProps) => {
const itemValues = useMemo(
() => getAccordionItemValues(children),
[children]
);
const isControlled = valueProp !== undefined;
const [internalSingle, setInternalSingle] = useState<string | undefined>(
typeof defaultValue === "string" ? defaultValue : undefined
);
const [internalMultiple, setInternalMultiple] = useState<string[]>(
Array.isArray(defaultValue) ? defaultValue : []
);
const singleValue = isControlled
? typeof valueProp === "string"
? valueProp
: undefined
: internalSingle;
const multipleValues = useMemo(() => {
if (type !== "multiple") {
return new Set<string>();
}
if (isControlled) {
return new Set(Array.isArray(valueProp) ? valueProp : []);
}
return new Set(internalMultiple);
}, [type, isControlled, valueProp, internalMultiple]);
const isOpen = useCallback(
(itemValue: string) => {
if (type === "multiple") {
return multipleValues.has(itemValue);
}
return singleValue === itemValue;
},
[type, singleValue, multipleValues]
);
const itemLayouts = useMemo(() => {
const layouts: Record<string, AccordionItemLayout> = {};
for (let index = 0; index < itemValues.length; index += 1) {
const itemValue = itemValues[index] ?? "";
layouts[itemValue] = isOpen(itemValue)
? "open"
: getClosedItemLayout(index, itemValues, isOpen);
}
return layouts;
}, [itemValues, isOpen]);
const toggle = useCallback(
(itemValue: string) => {
if (type === "multiple") {
const next = new Set(multipleValues);
if (next.has(itemValue)) {
next.delete(itemValue);
} else {
next.add(itemValue);
}
const arr = [...next];
if (!isControlled) {
setInternalMultiple(arr);
}
onValueChange?.(arr);
return;
}
let next: string | undefined;
if (singleValue === itemValue) {
next = collapsible ? undefined : itemValue;
} else {
next = itemValue;
}
if (!isControlled) {
setInternalSingle(next);
}
onValueChange?.(next);
},
[
type,
multipleValues,
singleValue,
collapsible,
isControlled,
onValueChange,
]
);
const ctx = useMemo(
() => ({
collapsible,
isOpen,
itemLayouts,
toggle,
type,
}),
[type, collapsible, itemLayouts, isOpen, toggle]
);
return (
<AccordionRootContext.Provider value={ctx}>
{children}
</AccordionRootContext.Provider>
);
};
export type AccordionItemProps = ViewProps & {
value: string;
children: ReactNode;
};
export const AccordionItem = ({
value,
className,
children,
...props
}: AccordionItemProps) => {
const root = useAccordionRoot();
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const open = root.isOpen(value);
const layout = root.itemLayouts[value] ?? "closed-only";
const itemCtx = useMemo(
() => ({
contentId,
isOpen: open,
triggerId,
value,
}),
[value, open, contentId, triggerId]
);
return (
<AccordionItemContext.Provider value={itemCtx}>
<AnimatedView
className={cn(
"overflow-hidden bg-card",
ACCORDION_ITEM_LAYOUT_CLASSNAME[layout],
className
)}
layout={ITEM_LAYOUT}
{...props}
>
{children}
</AnimatedView>
</AccordionItemContext.Provider>
);
};
export type AccordionTriggerProps = Omit<
ComponentPropsWithRef<typeof Pressable>,
"children"
> & {
asChild?: boolean;
children?: ReactNode;
};
export const AccordionTrigger = ({
asChild,
children,
className,
onPress,
ref: refProp,
...props
}: AccordionTriggerProps) => {
const root = useAccordionRoot();
const item = useAccordionItem();
const ref = useRef<ComponentRef<typeof Pressable>>(null);
const mergedRefs = mergeRefs(ref, refProp);
const handlePress = useCallback(
(e: GestureResponderEvent) => {
onPress?.(e);
root.toggle(item.value);
},
[onPress, root, item.value]
);
const Comp = asChild ? Slot.Pressable : Pressable;
return (
<Comp
accessibilityRole="button"
accessibilityState={{ expanded: item.isOpen }}
className={cn(
"flex flex-row items-center justify-between gap-2 bg-card px-3 py-3 active:opacity-75",
className
)}
nativeID={item.triggerId}
onPress={handlePress}
ref={mergedRefs}
{...props}
>
{children}
</Comp>
);
};
export type AccordionContentProps = ViewProps & {
children: ReactNode;
};
export const AccordionContent = ({
children,
className,
...props
}: AccordionContentProps) => {
const item = useAccordionItem();
if (!item.isOpen) {
return null;
}
return (
<Animated.View
entering={CONTENT_ENTER}
exiting={CONTENT_EXIT}
nativeID={item.contentId}
>
<View className={cn("px-3 pt-1 pb-3", className)} {...props}>
{children}
</View>
</Animated.View>
);
};
export type AccordionIndicatorProps = {
className?: string;
children?: ReactNode;
};
export const AccordionIndicator = ({
className,
children,
}: AccordionIndicatorProps) => {
const item = useAccordionItem();
const rotation = useSharedValue(item.isOpen ? 180 : 0);
useEffect(() => {
rotation.value = withSpring(item.isOpen ? 180 : 0, ICON_SPRING);
}, [item.isOpen, rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
if (children) {
return (
<Animated.View className="shrink-0" style={animatedStyle}>
{children}
</Animated.View>
);
}
return (
<Animated.View style={animatedStyle}>
<ChevronDownIcon
className={cn("size-5 shrink-0 text-muted-foreground", className)}
/>
</Animated.View>
);
};