Accordion Primitive
A vertically stacked set of interactive headings that each reveal a section of content.
Installation
Install the component via your command line.
npx expo install @rn-primitives/accordion
Install @radix-ui/react-accordion
npx expo install @radix-ui/react-accordion
Copy/paste the following code for web to ~/components/primitives/accordion/accordion.web.tsx
import * as Accordion from '@radix-ui/react-accordion';import { useAugmentedRef, useControllableState, useIsomorphicLayoutEffect,} from '~/components/primitives/hooks';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { Pressable, View } from 'react-native';import type { ContentProps, ContentRef, HeaderProps, HeaderRef, ItemProps, ItemRef, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
const AccordionContext = React.createContext<RootProps | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ( { asChild, value: valueProp, onValueChange: onValueChangeProps, defaultValue, type, disabled, dir, orientation = 'vertical', collapsible, ...props }, ref ) => { const [value = type === 'multiple' ? [] : undefined, onValueChange] = useControllableState< (string | undefined) | string[] >({ prop: valueProp, defaultProp: defaultValue, onChange: onValueChangeProps as (state: string | string[] | undefined) => void, });
const Component = asChild ? Slot.View : View; return ( <AccordionContext.Provider value={ { value, onValueChange, type, disabled, dir, orientation, } as RootProps } > <Accordion.Root asChild value={value as any} onValueChange={onValueChange as any} type={type as any} disabled={disabled} dir={dir} orientation={orientation} collapsible={collapsible} > <Component ref={ref} {...props} /> </Accordion.Root> </AccordionContext.Provider> ); });
Root.displayName = 'RootWebAccordion';
function useRootContext() { const context = React.useContext(AccordionContext); if (!context) { throw new Error( 'Accordion compound components cannot be rendered outside the Accordion component' ); } return context;}
const AccordionItemContext = React.createContext<(ItemProps & { isExpanded: boolean }) | null>( null);
const Item = React.forwardRef<ItemRef, ItemProps>( ({ asChild, value: itemValue, disabled, ...props }, ref) => { const augmentedRef = useAugmentedRef({ ref }); const { value, orientation, disabled: disabledRoot } = useRootContext();
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement; const isExpanded = Array.isArray(value) ? value.includes(itemValue) : value === itemValue; augRef.dataset.state = isExpanded ? 'open' : 'closed'; } }, [value, itemValue]);
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement; augRef.dataset.orientation = orientation; if (disabled || disabledRoot) { augRef.dataset.disabled = 'true'; } else { augRef.dataset.disabled = undefined; } } }, [orientation, disabled, disabledRoot]);
const Component = asChild ? Slot.View : View; return ( <AccordionItemContext.Provider value={{ value: itemValue, disabled, isExpanded: isItemExpanded(value, itemValue), }} > <Accordion.Item value={itemValue} disabled={disabled} asChild> <Component ref={augmentedRef} {...props} /> </Accordion.Item> </AccordionItemContext.Provider> ); });
Item.displayName = 'ItemWebAccordion';
function useItemContext() { const context = React.useContext(AccordionItemContext); if (!context) { throw new Error( 'AccordionItem compound components cannot be rendered outside the AccordionItem component' ); } return context;}
const Header = React.forwardRef<HeaderRef, HeaderProps>(({ asChild, ...props }, ref) => { const augmentedRef = useAugmentedRef({ ref }); const { disabled, isExpanded } = useItemContext(); const { orientation, disabled: disabledRoot } = useRootContext();
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement; augRef.dataset.state = isExpanded ? 'open' : 'closed'; } }, [isExpanded]);
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement; augRef.dataset.orientation = orientation; if (disabled || disabledRoot) { augRef.dataset.disabled = 'true'; } else { augRef.dataset.disabled = undefined; } } }, [orientation, disabled, disabledRoot]);
const Component = asChild ? Slot.View : View; return ( <Accordion.Header asChild> <Component ref={augmentedRef} {...props} /> </Accordion.Header> );});
Header.displayName = 'HeaderWebAccordion';
const HIDDEN_STYLE: React.CSSProperties = { position: 'absolute', top: 0, left: 0, zIndex: -999999, opacity: 0,};
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, disabled: disabledProp, ...props }, ref) => { const { disabled: disabledRoot } = useRootContext(); const { disabled, isExpanded } = useItemContext(); const triggerRef = React.useRef<HTMLButtonElement>(null); const augmentedRef = useAugmentedRef({ ref });
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement;
augRef.dataset.state = isExpanded ? 'expanded' : 'closed'; } }, [isExpanded]);
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement;
if (disabled || disabledRoot || disabledProp) { augRef.dataset.disabled = 'true'; } else { augRef.dataset.disabled = undefined; } } }, [disabled, disabledRoot, disabledProp]);
useIsomorphicLayoutEffect(() => { if (triggerRef.current) { triggerRef.current.disabled = true; } }, []);
const isDisabled = disabledProp ?? disabledRoot ?? disabled; const Component = asChild ? Slot.Pressable : Pressable; return ( <> <Accordion.Trigger ref={triggerRef} aria-hidden tabIndex={-1} style={HIDDEN_STYLE} /> <Accordion.Trigger disabled={isDisabled} asChild> <Component ref={augmentedRef} role='button' disabled={isDisabled} {...props} onPress={(ev) => { if (triggerRef.current && !isDisabled) { triggerRef.current.disabled = false; triggerRef.current.click(); triggerRef.current.disabled = true; } props.onPress?.(ev); }} /> </Accordion.Trigger> </> ); });
Trigger.displayName = 'TriggerWebAccordion';
const Content = React.forwardRef<ContentRef, ContentProps>( ({ asChild, forceMount, ...props }, ref) => { const augmentedRef = useAugmentedRef({ ref });
const { orientation, disabled: disabledRoot } = useRootContext(); const { disabled, isExpanded } = useItemContext(); useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement; augRef.dataset.state = isExpanded ? 'expanded' : 'closed'; } }, [isExpanded]);
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLDivElement; augRef.dataset.orientation = orientation;
if (disabled || disabledRoot) { augRef.dataset.disabled = 'true'; } else { augRef.dataset.disabled = undefined; } } }, [orientation, disabled, disabledRoot]);
const Component = asChild ? Slot.View : View; return ( <Accordion.Content forceMount={forceMount} asChild> <Component ref={augmentedRef} {...props} /> </Accordion.Content> ); });
Content.displayName = 'ContentWebAccordion';
export { Content, Header, Item, Root, Trigger, useItemContext, useRootContext };
function isItemExpanded(rootValue: string | string[] | undefined, value: string) { return Array.isArray(rootValue) ? rootValue.includes(value) : rootValue === value;}
Copy/paste the following code for native to ~/components/primitives/accordion/accordion.tsx
import { useControllableState } from '~/components/primitives/hooks';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { Pressable, View, type GestureResponderEvent } from 'react-native';import type { ContentProps, ContentRef, HeaderProps, HeaderRef, ItemProps, ItemRef, RootContext, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
const AccordionContext = React.createContext<RootContext | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ( { asChild, type, disabled, collapsible = true, value: valueProp, onValueChange: onValueChangeProps, defaultValue, ...viewProps }, ref ) => { const [value = type === 'multiple' ? [] : undefined, onValueChange] = useControllableState< (string | undefined) | string[] >({ prop: valueProp, defaultProp: defaultValue, onChange: onValueChangeProps as (state: string | string[] | undefined) => void, });
const Component = asChild ? Slot.View : View; return ( <AccordionContext.Provider value={{ type, disabled, collapsible, value, onValueChange, }} > <Component ref={ref} {...viewProps} /> </AccordionContext.Provider> ); });
Root.displayName = 'RootNativeAccordion';
function useRootContext() { const context = React.useContext(AccordionContext); if (!context) { throw new Error( 'Accordion compound components cannot be rendered outside the Accordion component' ); } return context;}
type AccordionItemContext = ItemProps & { nativeID: string; isExpanded: boolean;};
const AccordionItemContext = React.createContext<AccordionItemContext | null>(null);
const Item = React.forwardRef<ItemRef, ItemProps>( ({ asChild, value, disabled, ...viewProps }, ref) => { const { value: rootValue } = useRootContext(); const nativeID = React.useId();
const Component = asChild ? Slot.View : View; return ( <AccordionItemContext.Provider value={{ value, disabled, nativeID, isExpanded: isItemExpanded(rootValue, value), }} > <Component ref={ref} {...viewProps} /> </AccordionItemContext.Provider> ); });
Item.displayName = 'ItemNativeAccordion';
function useItemContext() { const context = React.useContext(AccordionItemContext); if (!context) { throw new Error( 'AccordionItem compound components cannot be rendered outside the AccordionItem component' ); } return context;}
const Header = React.forwardRef<HeaderRef, HeaderProps>(({ asChild, ...props }, ref) => { const { disabled: rootDisabled } = useRootContext(); const { disabled: itemDisabled, isExpanded } = useItemContext();
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} role='heading' aria-expanded={isExpanded} aria-disabled={rootDisabled ?? itemDisabled} {...props} /> );});
Header.displayName = 'HeaderNativeAccordion';
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled: disabledProp, ...props }, ref) => { const { disabled: rootDisabled, type, onValueChange, value: rootValue, collapsible, } = useRootContext(); const { nativeID, disabled: itemDisabled, value, isExpanded } = useItemContext();
function onPress(ev: GestureResponderEvent) { if (rootDisabled || itemDisabled) return; if (type === 'single') { const newValue = collapsible ? (value === rootValue ? undefined : value) : value; onValueChange(newValue); } if (type === 'multiple') { const rootToArray = toStringArray(rootValue); const newValue = collapsible ? rootToArray.includes(value) ? rootToArray.filter((val) => val !== value) : rootToArray.concat(value) : [...new Set(rootToArray.concat(value))]; // @ts-ignore - `newValue` is of type `string[]` which is OK onValueChange(newValue); } onPressProp?.(ev); }
const isDisabled = disabledProp || rootDisabled || itemDisabled; const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={ref} nativeID={nativeID} aria-disabled={isDisabled} role='button' onPress={onPress} accessibilityState={{ expanded: isExpanded, disabled: isDisabled, }} disabled={isDisabled} {...props} /> ); });
Trigger.displayName = 'TriggerNativeAccordion';
const Content = React.forwardRef<ContentRef, ContentProps>( ({ asChild, forceMount, ...props }, ref) => { const { type } = useRootContext(); const { nativeID, isExpanded } = useItemContext();
if (!forceMount) { if (!isExpanded) { return null; } }
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} aria-hidden={!(forceMount || isExpanded)} aria-labelledby={nativeID} role={type === 'single' ? 'region' : 'summary'} {...props} /> ); });
Content.displayName = 'ContentNativeAccordion';
export { Content, Header, Item, Root, Trigger, useItemContext, useRootContext };
function toStringArray(value?: string | string[]) { return Array.isArray(value) ? value : value ? [value] : [];}
function isItemExpanded(rootValue: string | string[] | undefined, value: string) { return Array.isArray(rootValue) ? rootValue.includes(value) : rootValue === value;}
Copy/paste the following code for types to ~/components/primitives/accordion/types.ts
import type { ForceMountable, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
type RootContext = { type: 'single' | 'multiple'; value: (string | undefined) | string[]; onValueChange: (value: string | undefined) => void | ((value: string[]) => void); collapsible: boolean; disabled?: boolean;};
type SingleRootProps = { type: 'single'; defaultValue?: string | undefined; value?: string | undefined; onValueChange?: (value: string | undefined) => void;};
type MultipleRootProps = { type: 'multiple'; defaultValue?: string[]; value?: string[]; onValueChange?: (value: string[]) => void;};
type RootProps = (SingleRootProps | MultipleRootProps) & { defaultValue?: string | string[]; disabled?: boolean; collapsible?: boolean; /** * Platform: WEB ONLY */ dir?: 'ltr' | 'rtl'; /** * Platform: WEB ONLY */ orientation?: 'vertical' | 'horizontal';} & SlottableViewProps;
type RootRef = ViewRef;
type ItemProps = { value: string; disabled?: boolean;} & SlottableViewProps;
type ItemRef = ViewRef;
type ContentProps = ForceMountable & SlottableViewProps;type ContentRef = ViewRef;type HeaderProps = SlottableViewProps;type HeaderRef = ViewRef;type TriggerProps = SlottablePressableProps;type TriggerRef = PressableRef;
export type { ContentProps, ContentRef, HeaderProps, HeaderRef, ItemProps, ItemRef, RootContext, RootProps, RootRef, TriggerProps, TriggerRef,};
Copy/paste the following code for exporting to ~/components/primitives/accordion/index.ts
export * from './accordion';export * from './types';
Copy/paste the following code for native to ~/components/primitives/accordion/index.tsx
import { useControllableState } from '~/components/primitives/hooks';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { Pressable, View, type GestureResponderEvent } from 'react-native';import type { ContentProps, ContentRef, HeaderProps, HeaderRef, ItemProps, ItemRef, RootContext, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
const AccordionContext = React.createContext<RootContext | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ( { asChild, type, disabled, collapsible = true, value: valueProp, onValueChange: onValueChangeProps, defaultValue, ...viewProps }, ref ) => { const [value = type === 'multiple' ? [] : undefined, onValueChange] = useControllableState< (string | undefined) | string[] >({ prop: valueProp, defaultProp: defaultValue, onChange: onValueChangeProps as (state: string | string[] | undefined) => void, });
const Component = asChild ? Slot.View : View; return ( <AccordionContext.Provider value={{ type, disabled, collapsible, value, onValueChange, }} > <Component ref={ref} {...viewProps} /> </AccordionContext.Provider> ); });
Root.displayName = 'RootNativeAccordion';
function useRootContext() { const context = React.useContext(AccordionContext); if (!context) { throw new Error( 'Accordion compound components cannot be rendered outside the Accordion component' ); } return context;}
type AccordionItemContext = ItemProps & { nativeID: string; isExpanded: boolean;};
const AccordionItemContext = React.createContext<AccordionItemContext | null>(null);
const Item = React.forwardRef<ItemRef, ItemProps>( ({ asChild, value, disabled, ...viewProps }, ref) => { const { value: rootValue } = useRootContext(); const nativeID = React.useId();
const Component = asChild ? Slot.View : View; return ( <AccordionItemContext.Provider value={{ value, disabled, nativeID, isExpanded: isItemExpanded(rootValue, value), }} > <Component ref={ref} {...viewProps} /> </AccordionItemContext.Provider> ); });
Item.displayName = 'ItemNativeAccordion';
function useItemContext() { const context = React.useContext(AccordionItemContext); if (!context) { throw new Error( 'AccordionItem compound components cannot be rendered outside the AccordionItem component' ); } return context;}
const Header = React.forwardRef<HeaderRef, HeaderProps>(({ asChild, ...props }, ref) => { const { disabled: rootDisabled } = useRootContext(); const { disabled: itemDisabled, isExpanded } = useItemContext();
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} role='heading' aria-expanded={isExpanded} aria-disabled={rootDisabled ?? itemDisabled} {...props} /> );});
Header.displayName = 'HeaderNativeAccordion';
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled: disabledProp, ...props }, ref) => { const { disabled: rootDisabled, type, onValueChange, value: rootValue, collapsible, } = useRootContext(); const { nativeID, disabled: itemDisabled, value, isExpanded } = useItemContext();
function onPress(ev: GestureResponderEvent) { if (rootDisabled || itemDisabled) return; if (type === 'single') { const newValue = collapsible ? (value === rootValue ? undefined : value) : value; onValueChange(newValue); } if (type === 'multiple') { const rootToArray = toStringArray(rootValue); const newValue = collapsible ? rootToArray.includes(value) ? rootToArray.filter((val) => val !== value) : rootToArray.concat(value) : [...new Set(rootToArray.concat(value))]; // @ts-ignore - `newValue` is of type `string[]` which is OK onValueChange(newValue); } onPressProp?.(ev); }
const isDisabled = disabledProp || rootDisabled || itemDisabled; const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={ref} nativeID={nativeID} aria-disabled={isDisabled} role='button' onPress={onPress} accessibilityState={{ expanded: isExpanded, disabled: isDisabled, }} disabled={isDisabled} {...props} /> ); });
Trigger.displayName = 'TriggerNativeAccordion';
const Content = React.forwardRef<ContentRef, ContentProps>( ({ asChild, forceMount, ...props }, ref) => { const { type } = useRootContext(); const { nativeID, isExpanded } = useItemContext();
if (!forceMount) { if (!isExpanded) { return null; } }
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} aria-hidden={!(forceMount || isExpanded)} aria-labelledby={nativeID} role={type === 'single' ? 'region' : 'summary'} {...props} /> ); });
Content.displayName = 'ContentNativeAccordion';
export { Content, Header, Item, Root, Trigger, useItemContext, useRootContext };
function toStringArray(value?: string | string[]) { return Array.isArray(value) ? value : value ? [value] : [];}
function isItemExpanded(rootValue: string | string[] | undefined, value: string) { return Array.isArray(rootValue) ? rootValue.includes(value) : rootValue === value;}
Copy/paste the following code for types to ~/components/primitives/accordion/types.ts
import type { ForceMountable, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
type RootContext = { type: 'single' | 'multiple'; value: (string | undefined) | string[]; onValueChange: (value: string | undefined) => void | ((value: string[]) => void); collapsible: boolean; disabled?: boolean;};
type SingleRootProps = { type: 'single'; defaultValue?: string | undefined; value?: string | undefined; onValueChange?: (value: string | undefined) => void;};
type MultipleRootProps = { type: 'multiple'; defaultValue?: string[]; value?: string[]; onValueChange?: (value: string[]) => void;};
type RootProps = (SingleRootProps | MultipleRootProps) & { defaultValue?: string | string[]; disabled?: boolean; collapsible?: boolean; /** * Platform: WEB ONLY */ dir?: 'ltr' | 'rtl'; /** * Platform: WEB ONLY */ orientation?: 'vertical' | 'horizontal';} & SlottableViewProps;
type RootRef = ViewRef;
type ItemProps = { value: string; disabled?: boolean;} & SlottableViewProps;
type ItemRef = ViewRef;
type ContentProps = ForceMountable & SlottableViewProps;type ContentRef = ViewRef;type HeaderProps = SlottableViewProps;type HeaderRef = ViewRef;type TriggerProps = SlottablePressableProps;type TriggerRef = PressableRef;
export type { ContentProps, ContentRef, HeaderProps, HeaderRef, ItemProps, ItemRef, RootContext, RootProps, RootRef, TriggerProps, TriggerRef,};
Usage
import * as AccordionPrimitive from '@rn-primitives/accordion';
function Example() { return ( <AccordionPrimitive.Root type='multiple' collapsible defaultValue={['item-1']} > <AccordionPrimitive.Item value='item-1'> <AccordionPrimitive.Trigger> <Text>Is it accessible?</Text> </AccordionPrimitive.Trigger> <AccordionPrimitive.Content> <Text>Yes. It adheres to the WAI-ARIA design pattern.</Text> </AccordionPrimitive.Content> </AccordionPrimitive.Item> <AccordionPrimitive.Item value='item-2'> <AccordionPrimitive.Trigger> <Text>What are universal components?</Text> </AccordionPrimitive.Trigger> <AccordionPrimitive.Content> <Text> In the world of React Native, universal components are components that work on both web and native platforms. </Text> </AccordionPrimitive.Content> </AccordionPrimitive.Item> </AccordionPrimitive.Root> );}
Props
Root
Extends View
props
Prop | Type | Note |
---|---|---|
type* | ‘single’ | ‘multiple’ | |
asChild | boolean | (optional) |
defaultValue | (string | undefined) | string[] | (optional) |
value | (string | undefined) | string[] | (optional) |
onValueChange | ((string | undefined) => void) | ((string[]) => void) | (optional) |
dir | ’ltr’ | ‘rtl’ | Web only (optional) |
orientation | ’vertical’ | ‘horizontal’ | Web only (optional) |
Item
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
value | string | (optional) |
disabled | boolean | (optional) |
Header
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
Trigger
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
Content
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined | (optional) |
useRootContext
Must be used within a Root
component. It provides the following values from the accordion: type
, disabled
, collapsible
, value
, and onValueChange
.
useItemContext
Must be used within an Item
component. It provides the following values from the accordion item: value
, disabled
, and isExpanded
.