Accordion

Muestra y oculta contenido expandible.

Want to survive 12-hour coding sessions? Always keep snacks nearby and caffeine on standby. Bonus points for comfy socks and a chair that doesn't destroy your back.

Remember: comments are your friend. Future you will thank past you for writing clear notes.

1'use client';
2
3import { Accordion } from '@/components/ui/accordion';
4
5export function Default() {
6 return (
7 <Accordion type="single" className="w-full" defaultValue="item-1">
8 <Accordion.Item value="item-1">
9 <Accordion.Trigger value="item-1">Life Hacks for Coders</Accordion.Trigger>
10 <Accordion.Content value="item-1" className="flex flex-col gap-4 text-balance">
11 <p>
12 Want to survive 12-hour coding sessions? Always keep snacks nearby and caffeine on
13 standby. Bonus points for comfy socks and a chair that doesn't destroy your back.
14 </p>
15 <p>
16 Remember: comments are your friend. Future you will thank past you for writing clear
17 notes.
18 </p>
19 </Accordion.Content>
20 </Accordion.Item>
21 <Accordion.Item value="item-2">
22 <Accordion.Trigger value="item-2">Debugging Secrets</Accordion.Trigger>
23 <Accordion.Content value="item-2" className="flex flex-col gap-4 text-balance">
24 <p>
25 Debugging is basically detective work, but your suspects are lines of code. Breakpoints
26 are your magnifying glass.
27 </p>
28 <p>
29 Pro tip: if it compiles but doesn't work, stare at the screen, whisper "why won't you
30 work?," then Google like your life depends on it.
31 </p>
32 </Accordion.Content>
33 </Accordion.Item>
34 <Accordion.Item value="item-3">
35 <Accordion.Trigger value="item-3">Random Productivity Tips</Accordion.Trigger>
36 <Accordion.Content value="item-3" className="flex flex-col gap-4 text-balance">
37 <p>
38 Sometimes the best way to get code done is to step away. Take a walk, pet your cat, or
39 pretend to meditate.
40 </p>
41 <p>And remember: Ctrl+S is life. Save often, panic never.</p>
42 </Accordion.Content>
43 </Accordion.Item>
44 </Accordion>
45 );
46}

Instalación

Copia y pega el siguiente código en tu proyecto.
'use client';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import * as React from 'react';
import { cn } from '../lib/cn';
// --- Animation variants (hoisted at module level to avoid recreation per render) ---
const CHEVRON_VARIANTS = {
open: { rotate: 180 },
closed: { rotate: 0 },
} as const;
const CHEVRON_TRANSITION = {
type: 'spring',
stiffness: 200,
damping: 15,
} as const;
const CHEVRON_STYLE = { willChange: 'transform' } as const;
const CONTENT_HEIGHT_VARIANTS = {
open: { height: 'auto' },
closed: { height: 0 },
} as const;
const CONTENT_HEIGHT_TRANSITION = {
duration: 0.3,
ease: [0.04, 0.62, 0.23, 0.98] as [number, number, number, number],
} as const;
const CONTENT_FADE_VARIANTS = {
open: { y: 0, opacity: 1, filter: 'blur(0px)' },
closed: { y: -15, opacity: 0, filter: 'blur(6px)' },
} as const;
const CONTENT_FADE_TRANSITIONS = {
enter: { duration: 0.35, ease: 'easeOut' },
exit: { duration: 0.2, ease: 'easeIn' },
} as const;
const CONTENT_STYLE = { willChange: 'opacity, transform, filter' } as const;
// --- Context ---
type AccordionContextValue = {
type: 'single' | 'multiple';
openItems: string[];
toggleItem: (value: string) => void;
};
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
const useAccordionContext = () => {
const context = React.use(AccordionContext);
if (!context) {
throw new Error('Accordion components must be used within an Accordion');
}
return context;
};
// --- Components ---
interface AccordionProps {
type?: 'single' | 'multiple';
defaultValue?: string;
children?: React.ReactNode;
className?: string;
value?: string[];
onValueChange?: (value: string[]) => void;
}
const AccordionRoot = ({
type = 'single',
defaultValue,
children,
className,
value,
onValueChange,
ref,
}: AccordionProps & { ref?: React.Ref<HTMLDivElement> }) => {
const [uncontrolledValue, setUncontrolledValue] = React.useState<string[]>(
defaultValue ? [defaultValue] : [],
);
const isControlled = value !== undefined;
const openItems = isControlled ? value : uncontrolledValue;
const toggleItem = React.useCallback(
(itemValue: string) => {
const updater = (prev: string[]) => {
if (type === 'single') {
return prev.includes(itemValue) ? [] : [itemValue];
}
return prev.includes(itemValue)
? prev.filter((v) => v !== itemValue)
: [...prev, itemValue];
};
if (!isControlled) {
setUncontrolledValue((prev) => {
const newValue = updater(prev);
onValueChange?.(newValue);
return newValue;
});
} else {
onValueChange?.(updater(openItems));
}
},
[type, isControlled, openItems, onValueChange],
);
return (
<AccordionContext value={{ type, openItems, toggleItem }}>
<div ref={ref} className={cn('w-full space-y-2', className)}>
{children}
</div>
</AccordionContext>
);
};
AccordionRoot.displayName = 'Accordion';
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
children?: React.ReactNode;
}
const AccordionItem = ({
value,
children,
className,
ref,
...props
}: AccordionItemProps & { ref?: React.Ref<HTMLDivElement> }) => {
return (
<div
ref={ref}
className={cn('overflow-hidden border-b border-white/5 last:border-0', className)}
{...props}
>
{children}
</div>
);
};
AccordionItem.displayName = 'AccordionItem';
interface AccordionTriggerProps {
children?: React.ReactNode;
value: string;
className?: string;
}
const AccordionTrigger = ({
children,
value,
className,
ref,
}: AccordionTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {
const { openItems, toggleItem } = useAccordionContext();
const isOpen = openItems.includes(value);
const shouldReduceMotion = useReducedMotion();
const triggerId = `accordion-trigger-${value.replace(/[^a-zA-Z0-9-]/gi, '')}`;
const contentId = `accordion-content-${value.replace(/[^a-zA-Z0-9-]/gi, '')}`;
return (
<motion.button
ref={ref}
id={triggerId}
type="button"
aria-controls={contentId}
aria-expanded={isOpen}
onClick={() => toggleItem(value)}
whileTap={shouldReduceMotion ? undefined : { scale: 0.98 }}
className={cn(
'group flex w-full items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline',
className,
)}
>
{children}
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground group-hover:text-primary h-4 w-4 shrink-0 transition-colors"
variants={CHEVRON_VARIANTS}
animate={shouldReduceMotion ? undefined : isOpen ? 'open' : 'closed'}
transition={CHEVRON_TRANSITION}
style={CHEVRON_STYLE}
>
<path d="m6 9 6 6 6-6" />
</motion.svg>
</motion.button>
);
};
AccordionTrigger.displayName = 'AccordionTrigger';
interface AccordionContentProps {
children?: React.ReactNode;
value: string;
className?: string;
}
const AccordionContent = ({
children,
value,
className,
ref,
}: AccordionContentProps & { ref?: React.Ref<HTMLDivElement> }) => {
const { openItems } = useAccordionContext();
const isOpen = openItems.includes(value);
const triggerId = `accordion-trigger-${value.replace(/[^a-zA-Z0-9-]/gi, '')}`;
const contentId = `accordion-content-${value.replace(/[^a-zA-Z0-9-]/gi, '')}`;
return (
<AnimatePresence initial={false} mode="wait">
{isOpen && (
<motion.div
ref={ref}
id={contentId}
role="region"
aria-labelledby={triggerId}
key="content"
variants={CONTENT_HEIGHT_VARIANTS}
initial="closed"
animate="open"
exit="closed"
transition={CONTENT_HEIGHT_TRANSITION}
className={cn('overflow-hidden text-sm', className)}
>
<motion.div
variants={CONTENT_FADE_VARIANTS}
initial="closed"
animate="open"
exit={{ ...CONTENT_FADE_VARIANTS.closed, transition: CONTENT_FADE_TRANSITIONS.exit }}
transition={CONTENT_FADE_TRANSITIONS.enter}
style={CONTENT_STYLE}
className="text-muted-foreground pt-0 pb-4"
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
AccordionContent.displayName = 'AccordionContent';
const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
export { Accordion };
export type { AccordionContentProps, AccordionItemProps, AccordionProps, AccordionTriggerProps };
Asegúrate de actualizar las rutas de importación según la estructura de tu proyecto.

Anatomía

import { Accordion } from '@/components/ui/accordion';
<Accordion type="single" defaultValue="item-1">
<Accordion.Item value="item-1">
<Accordion.Trigger>¿Es accesible?</Accordion.Trigger>
<Accordion.Content>Sí. Se adhiere al estándar de diseño WAI-ARIA.</Accordion.Content>
</Accordion.Item>
</Accordion>

Ejemplos

Single

El tipo single permite que solo un ítem esté abierto a la vez.

Want to survive 12-hour coding sessions? Always keep snacks nearby and caffeine on standby. Bonus points for comfy socks and a chair that doesn't destroy your back.

Remember: comments are your friend. Future you will thank past you for writing clear notes.

1'use client';
2
3import { Accordion } from '@/components/ui/accordion';
4
5export function Default() {
6 return (
7 <Accordion type="single" className="w-full" defaultValue="item-1">
8 <Accordion.Item value="item-1">
9 <Accordion.Trigger value="item-1">Life Hacks for Coders</Accordion.Trigger>
10 <Accordion.Content value="item-1" className="flex flex-col gap-4 text-balance">
11 <p>
12 Want to survive 12-hour coding sessions? Always keep snacks nearby and caffeine on
13 standby. Bonus points for comfy socks and a chair that doesn't destroy your back.
14 </p>
15 <p>
16 Remember: comments are your friend. Future you will thank past you for writing clear
17 notes.
18 </p>
19 </Accordion.Content>
20 </Accordion.Item>
21 <Accordion.Item value="item-2">
22 <Accordion.Trigger value="item-2">Debugging Secrets</Accordion.Trigger>
23 <Accordion.Content value="item-2" className="flex flex-col gap-4 text-balance">
24 <p>
25 Debugging is basically detective work, but your suspects are lines of code. Breakpoints
26 are your magnifying glass.
27 </p>
28 <p>
29 Pro tip: if it compiles but doesn't work, stare at the screen, whisper "why won't you
30 work?," then Google like your life depends on it.
31 </p>
32 </Accordion.Content>
33 </Accordion.Item>
34 <Accordion.Item value="item-3">
35 <Accordion.Trigger value="item-3">Random Productivity Tips</Accordion.Trigger>
36 <Accordion.Content value="item-3" className="flex flex-col gap-4 text-balance">
37 <p>
38 Sometimes the best way to get code done is to step away. Take a walk, pet your cat, or
39 pretend to meditate.
40 </p>
41 <p>And remember: Ctrl+S is life. Save often, panic never.</p>
42 </Accordion.Content>
43 </Accordion.Item>
44 </Accordion>
45 );
46}

Multiple

El tipo multiple permite que múltiples ítems estén abiertos simultáneamente.
1'use client';
2
3import { Accordion } from '@/components/ui/accordion';
4
5export function Multiple() {
6 return (
7 <Accordion type="multiple" className="w-full">
8 <Accordion.Item value="item-1">
9 <Accordion.Trigger value="item-1">Can I open multiple items?</Accordion.Trigger>
10 <Accordion.Content value="item-1">
11 Yes! When using type="multiple", you can have multiple accordion items open at the same
12 time.
13 </Accordion.Content>
14 </Accordion.Item>
15 <Accordion.Item value="item-2">
16 <Accordion.Trigger value="item-2">How does it work?</Accordion.Trigger>
17 <Accordion.Content value="item-2">
18 Simply set the type prop to "multiple" and users can expand as many sections as they want
19 simultaneously.
20 </Accordion.Content>
21 </Accordion.Item>
22 <Accordion.Item value="item-3">
23 <Accordion.Trigger value="item-3">Is this useful?</Accordion.Trigger>
24 <Accordion.Content value="item-3">
25 Absolutely! It's perfect for FAQ sections where users might want to compare multiple
26 answers at once.
27 </Accordion.Content>
28 </Accordion.Item>
29 </Accordion>
30 );
31}

Sin valor por defecto

Puedes omitir defaultValue para que el acordeón inicie completamente cerrado.
1'use client';
2
3import { Accordion } from '@/components/ui/accordion';
4
5export function Collapsed() {
6 return (
7 <Accordion type="single" className="w-full">
8 <Accordion.Item value="item-1">
9 <Accordion.Trigger value="item-1">Will it start closed?</Accordion.Trigger>
10 <Accordion.Content value="item-1">
11 Yes! When you don't provide a defaultValue prop, all items start in a collapsed state.
12 </Accordion.Content>
13 </Accordion.Item>
14 <Accordion.Item value="item-2">
15 <Accordion.Trigger value="item-2">Can users still open items?</Accordion.Trigger>
16 <Accordion.Content value="item-2">
17 Of course! Users can click any trigger to expand the content. It just starts fully
18 collapsed.
19 </Accordion.Content>
20 </Accordion.Item>
21 <Accordion.Item value="item-3">
22 <Accordion.Trigger value="item-3">When is this useful?</Accordion.Trigger>
23 <Accordion.Content value="item-3">
24 This is great when you want users to actively choose what information they want to see,
25 keeping the interface clean initially.
26 </Accordion.Content>
27 </Accordion.Item>
28 </Accordion>
29 );
30}

Referencia de API

Accordion

PropTipoDefaultDescripción
type"single" | "multiple""single"Controla si se puede abrir un solo ítem o varios simultáneamente.
defaultValuestringundefinedÍtem inicial abierto.
childrenReact.ReactNodeDeben ser AccordionItem.
classNamestringClases adicionales para el contenedor.

Accordion.Item

PropTipoDefaultDescripción
valuestringID único del ítem. Obligatorio.
childrenReact.ReactNodeDebe incluir AccordionTrigger y AccordionContent.
isOpenbooleanControlado internoIndica si el ítem está abierto.
onToggle() => voidControlado internoFunción para abrir/cerrar.
classNamestringClases adicionales para el wrapper.

Accordion.Trigger

PropTipoDefaultDescripción
childrenReact.ReactNodeTítulo o contenido del botón.
isOpenbooleanControlado internoEstado abierto/cerrado (rota el ícono).
onToggle() => voidControlado internoAcción al hacer click.
classNamestringClases adicionales del botón.

Accordion.Content

PropTipoDefaultDescripción
childrenReact.ReactNodeContenido colapsable.
isOpenbooleanControlado internoControla apertura y animación.
classNamestringClases adicionales para el contenedor animado.