Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Installation
bash npx @moe/cli add accordion Install the following dependency:
npx expo install @rn-primitives/accordionCopy/paste the following code to @/components/ui/accordion.tsx
import * as AccordionPrimitive from "@rn-primitives/accordion";
import * as React from "react";
import { Platform, Pressable, View } from "react-native";
import Animated, {
Extrapolation,
FadeIn,
FadeOutUp,
LayoutAnimationConfig,
LinearTransition,
interpolate,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { ChevronDown } from "@/lib/icons/ChevronDown";
import { cn } from "@/lib/utils";
import { TextClassContext } from "@/components/ui/text";
const Accordion = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>
>(({ children, ...props }, ref) => {
return (
<LayoutAnimationConfig skipEntering>
<AccordionPrimitive.Root ref={ref} {...props}>
<Animated.View layout={LinearTransition.duration(200)}>
{children}
</Animated.View>
</AccordionPrimitive.Root>
</LayoutAnimationConfig>
);
});
Accordion.displayName = AccordionPrimitive.Root.displayName;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, value, ...props }, ref) => {
return (
<Animated.View
className={"overflow-hidden"}
layout={LinearTransition.duration(200)}
>
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-border", className)}
value={value}
{...props}
/>
</Animated.View>
);
});
AccordionItem.displayName = AccordionPrimitive.Item.displayName;
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof Pressable>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => {
const { isExpanded } = AccordionPrimitive.useItemContext();
const progress = useDerivedValue(() =>
isExpanded
? withTiming(1, { duration: 200 })
: withTiming(0, { duration: 200 }),
);
const chevronStyle = useAnimatedStyle(() => ({
transform: [
{
rotate: `${interpolate(progress.value, [0, 1], [0, 180], Extrapolation.CLAMP)}deg`,
},
],
opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolation.CLAMP),
}));
return (
<TextClassContext.Provider value="native:text-lg font-medium web:group-hover:underline">
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger ref={ref} {...props} asChild>
<Pressable
className={cn(
"flex flex-1 flex-row items-center justify-between py-4 web:transition-all group web:focus-visible:outline-none web:focus-visible:ring-1 web:focus-visible:ring-ring",
className,
)}
>
<>{children}</>
<Animated.View style={chevronStyle}>
<ChevronDown
size={18}
className={cn("text-foreground shrink-0")}
/>
</Animated.View>
</Pressable>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
</TextClassContext.Provider>
);
});
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => {
const { isExpanded } = AccordionPrimitive.useItemContext();
return (
<TextClassContext.Provider value="native:text-base">
<AccordionPrimitive.Content
className={cn("overflow-hidden text-sm", className)}
ref={ref}
{...props}
>
<Animated.View
entering={Platform.OS !== "web" ? FadeIn.duration(200) : undefined}
exiting={Platform.OS !== "web" ? FadeOutUp.duration(200) : undefined}
>
<View className={cn("pb-4 pt-0", className)}>{children}</View>
</Animated.View>
</AccordionPrimitive.Content>
</TextClassContext.Provider>
);
});
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };Update tailwind.config.js
Add the following animations:
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
extend: {
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
};Usage
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@moe/registry/ui/accordion";
export function AccordionDemo() {
return (
<Accordion type="single" collapsible className="w-full max-w-sm">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other components'
aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It's animated by default, but you can disable it if you prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'single' | 'multiple' | 'single' | Whether a single or multiple items can be opened at the same time |
collapsible | boolean | false | When type is "single", allows closing content when clicking trigger for an open item |
value | string | string[] | - | The controlled value of the item to expand |
onValueChange | (value: string | string[]) => void | - | Event handler called when the expanded state changes |