shadimage
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 · light
react-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>
  );
};

More from tetra-ui

Browse all components in this library →