Accordion
Alternar secciones de contenido. Mantener layouts limpios. Enfocar atención.
Every teammate you invite takes a seat. New seats are prorated for the days left in the billing period, and seats you remove come back as credit on the next invoice.
1'use client';23import { Accordion } from '@/components/ui/accordion';45const faq = [6 {7 value: 'seats',8 question: 'How are seats billed?',9 answer:10 'Every teammate you invite takes a seat. New seats are prorated for the days left in the billing period, and seats you remove come back as credit on the next invoice.',11 },12 {13 value: 'plan-change',14 question: 'Can I switch plans in the middle of a cycle?',15 answer:16 'Yes. Upgrades apply right away and you pay the difference for the rest of the month. Downgrades take effect on the renewal date so you keep what you already paid for.',17 },18 {19 value: 'invoices',20 question: 'Where do I find past invoices?',21 answer:22 'Open Settings, then Billing. Every invoice is there as a PDF, and the billing contact gets a copy by email within a few minutes of each charge.',23 },24 {25 value: 'overage',26 question: 'What happens if we go over the included usage?',27 answer:28 'We keep serving traffic and add the extra usage to your next invoice at the rate listed on your plan. You get an email as soon as you pass 80% of the included quota.',29 },30];3132export function Default() {33 return (34 <Accordion type="single" className="w-full max-w-md" defaultValue="seats">35 {faq.map((item) => (36 <Accordion.Item key={item.value} value={item.value}>37 <Accordion.Trigger value={item.value}>{item.question}</Accordion.Trigger>38 <Accordion.Content value={item.value} className="text-muted-foreground text-sm">39 {item.answer}40 </Accordion.Content>41 </Accordion.Item>42 ))}43 </Accordion>44 );45}
Instalación
Copia y pega el siguiente código en tu proyecto.
1'use client';23import { AnimatePresence, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { collapse, collapseInner, springs, still, tap } from '../lib/motion';7import { useControllableState } from '../lib/use-controllable-state';89const CHEVRON_VARIANTS = {10 open: { rotate: 180 },11 closed: { rotate: 0 },12} as const;1314const CHEVRON_STYLE = { willChange: 'transform' } as const;1516const CONTENT_STYLE = { willChange: 'opacity, transform, filter' } as const;1718const REDUCED_COLLAPSE = {19 closed: { height: 0, opacity: 0, transition: still },20 open: { height: 'auto', opacity: 1, transition: still },21} as const;2223type AccordionHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;2425type AccordionContextValue = {26 type: 'single' | 'multiple';27 openItems: string[];28 toggleItem: (value: string) => void;29 baseId: string;30 headingLevel: AccordionHeadingLevel;31};3233const slugify = (value: string) => value.replace(/[^a-zA-Z0-9-]/gi, '');3435const AccordionContext = React.createContext<AccordionContextValue | null>(null);3637const useAccordionContext = () => {38 const context = React.use(AccordionContext);39 if (!context) {40 throw new Error('Accordion components must be used within an Accordion');41 }42 return context;43};4445interface AccordionProps {46 type?: 'single' | 'multiple';47 defaultValue?: string | string[];48 children?: React.ReactNode;49 className?: string;50 value?: string[];51 onValueChange?: (value: string[]) => void;52 headingLevel?: AccordionHeadingLevel;53}5455const AccordionRoot = ({56 type = 'single',57 defaultValue,58 children,59 className,60 value,61 onValueChange,62 headingLevel = 3,63 ref,64}: AccordionProps & { ref?: React.Ref<HTMLDivElement> }) => {65 const [openItems, setOpenItems] = useControllableState<string[]>({66 value,67 defaultValue: defaultValue ? [defaultValue].flat() : [],68 onChange: onValueChange,69 });70 const baseId = React.useId();7172 const toggleItem = React.useCallback(73 (itemValue: string) => {74 setOpenItems((prev) => {75 if (type === 'single') {76 return prev.includes(itemValue) ? [] : [itemValue];77 }78 return prev.includes(itemValue)79 ? prev.filter((v) => v !== itemValue)80 : [...prev, itemValue];81 });82 },83 [type, setOpenItems],84 );8586 const contextValue = React.useMemo<AccordionContextValue>(87 () => ({ type, openItems, toggleItem, baseId, headingLevel }),88 [type, openItems, toggleItem, baseId, headingLevel],89 );9091 return (92 <AccordionContext value={contextValue}>93 <div ref={ref} data-accordion-root="" className={cn('w-full space-y-2', className)}>94 {children}95 </div>96 </AccordionContext>97 );98};99100AccordionRoot.displayName = 'Accordion';101102interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {103 value: string;104 children?: React.ReactNode;105}106107const AccordionItem = ({108 value: _value,109 children,110 className,111 ref,112 ...props113}: AccordionItemProps & { ref?: React.Ref<HTMLDivElement> }) => {114 return (115 <div116 ref={ref}117 className={cn('border-border overflow-hidden border-b last:border-0', className)}118 {...props}119 >120 {children}121 </div>122 );123};124125AccordionItem.displayName = 'AccordionItem';126127interface AccordionTriggerProps {128 children?: React.ReactNode;129 value: string;130 className?: string;131 headingLevel?: AccordionHeadingLevel;132}133134const AccordionTrigger = ({135 children,136 value,137 className,138 headingLevel,139 ref,140}: AccordionTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {141 const context = useAccordionContext();142 const { openItems, toggleItem, baseId } = context;143 const Heading = `h${headingLevel ?? context.headingLevel}` as const;144 const isOpen = openItems.includes(value);145 const shouldReduceMotion = useReducedMotion();146 const triggerId = `${baseId}-trigger-${slugify(value)}`;147 const contentId = `${baseId}-content-${slugify(value)}`;148149 const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {150 if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;151152 const root = e.currentTarget.closest<HTMLElement>('[data-accordion-root]');153 if (!root) return;154155 const triggers = Array.from(156 root.querySelectorAll<HTMLButtonElement>('[data-accordion-trigger]'),157 );158 const currentIndex = triggers.indexOf(e.currentTarget);159 if (currentIndex < 0) return;160161 e.preventDefault();162 let nextIndex = currentIndex;163 if (e.key === 'ArrowDown') nextIndex = (currentIndex + 1) % triggers.length;164 else if (e.key === 'ArrowUp')165 nextIndex = (currentIndex - 1 + triggers.length) % triggers.length;166 else if (e.key === 'Home') nextIndex = 0;167 else if (e.key === 'End') nextIndex = triggers.length - 1;168169 triggers[nextIndex]?.focus();170 };171172 return (173 <Heading className="flex">174 <motion.button175 ref={ref}176 id={triggerId}177 type="button"178 data-accordion-trigger=""179 aria-controls={isOpen ? contentId : undefined}180 aria-expanded={isOpen}181 onClick={() => toggleItem(value)}182 onKeyDown={handleKeyDown}183 whileTap={shouldReduceMotion ? undefined : tap}184 transition={springs.snappy}185 className={cn(186 'group hover:text-muted-foreground ring-offset-background focus-visible:ring-ring flex w-full items-center justify-between rounded-sm py-3.5 text-left text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-offset-2',187 className,188 )}189 >190 {children}191 <motion.svg192 aria-hidden="true"193 xmlns="http://www.w3.org/2000/svg"194 width="24"195 height="24"196 viewBox="0 0 24 24"197 fill="none"198 stroke="currentColor"199 strokeWidth="2"200 strokeLinecap="round"201 strokeLinejoin="round"202 className="text-muted-foreground group-hover:text-primary h-4 w-4 shrink-0 transition-colors"203 variants={CHEVRON_VARIANTS}204 animate={isOpen ? 'open' : 'closed'}205 transition={shouldReduceMotion ? still : springs.snappy}206 style={CHEVRON_STYLE}207 >208 <path d="m6 9 6 6 6-6" />209 </motion.svg>210 </motion.button>211 </Heading>212 );213};214215AccordionTrigger.displayName = 'AccordionTrigger';216217interface AccordionContentProps {218 children?: React.ReactNode;219 value: string;220 className?: string;221}222223const AccordionContent = ({224 children,225 value,226 className,227 ref,228}: AccordionContentProps & { ref?: React.Ref<HTMLDivElement> }) => {229 const { openItems, baseId } = useAccordionContext();230 const isOpen = openItems.includes(value);231 const shouldReduceMotion = useReducedMotion();232 const triggerId = `${baseId}-trigger-${slugify(value)}`;233 const contentId = `${baseId}-content-${slugify(value)}`;234235 return (236 <AnimatePresence initial={false} mode="wait">237 {isOpen && (238 <motion.div239 ref={ref}240 id={contentId}241 role="region"242 aria-labelledby={triggerId}243 key="content"244 variants={shouldReduceMotion ? REDUCED_COLLAPSE : collapse}245 initial="closed"246 animate="open"247 exit="closed"248 className={cn('overflow-hidden text-sm', className)}249 >250 <motion.div251 variants={shouldReduceMotion ? undefined : collapseInner}252 initial="closed"253 animate="open"254 exit="closed"255 style={shouldReduceMotion ? undefined : CONTENT_STYLE}256 className="text-muted-foreground pt-0 pb-4"257 >258 {children}259 </motion.div>260 </motion.div>261 )}262 </AnimatePresence>263 );264};265266AccordionContent.displayName = 'AccordionContent';267268const Accordion = Object.assign(AccordionRoot, {269 Item: AccordionItem,270 Trigger: AccordionTrigger,271 Content: AccordionContent,272});273274export { Accordion };275export type {276 AccordionContentProps,277 AccordionHeadingLevel,278 AccordionItemProps,279 AccordionProps,280 AccordionTriggerProps,281};
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { Accordion } from '@/components/ui/accordion';
1<Accordion type="single" defaultValue="item-1">2 <Accordion.Item value="item-1">3 <Accordion.Trigger>¿Es accesible?</Accordion.Trigger>4 <Accordion.Content>Sí. Se adhiere al estándar de diseño WAI-ARIA.</Accordion.Content>5 </Accordion.Item>6</Accordion>
Composición
Usa la siguiente composición para construir un
Accordion:Accordion└── Accordion.Item├── Accordion.Trigger└── Accordion.Content
Variantes
Single
El tipo
single permite que solo un ítem esté abierto a la vez.Every teammate you invite takes a seat. New seats are prorated for the days left in the billing period, and seats you remove come back as credit on the next invoice.
1'use client';23import { Accordion } from '@/components/ui/accordion';45const faq = [6 {7 value: 'seats',8 question: 'How are seats billed?',9 answer:10 'Every teammate you invite takes a seat. New seats are prorated for the days left in the billing period, and seats you remove come back as credit on the next invoice.',11 },12 {13 value: 'plan-change',14 question: 'Can I switch plans in the middle of a cycle?',15 answer:16 'Yes. Upgrades apply right away and you pay the difference for the rest of the month. Downgrades take effect on the renewal date so you keep what you already paid for.',17 },18 {19 value: 'invoices',20 question: 'Where do I find past invoices?',21 answer:22 'Open Settings, then Billing. Every invoice is there as a PDF, and the billing contact gets a copy by email within a few minutes of each charge.',23 },24 {25 value: 'overage',26 question: 'What happens if we go over the included usage?',27 answer:28 'We keep serving traffic and add the extra usage to your next invoice at the rate listed on your plan. You get an email as soon as you pass 80% of the included quota.',29 },30];3132export function Default() {33 return (34 <Accordion type="single" className="w-full max-w-md" defaultValue="seats">35 {faq.map((item) => (36 <Accordion.Item key={item.value} value={item.value}>37 <Accordion.Trigger value={item.value}>{item.question}</Accordion.Trigger>38 <Accordion.Content value={item.value} className="text-muted-foreground text-sm">39 {item.answer}40 </Accordion.Content>41 </Accordion.Item>42 ))}43 </Accordion>44 );45}
Multiple
El tipo
multiple permite que múltiples ítems estén abiertos simultáneamente.1'use client';23import { Accordion } from '@/components/ui/accordion';45const causes = [6 {7 value: 'module-not-found',8 trigger: 'Module not found during the build step',9 content:10 'The package resolves locally but not on the build machine. Move it out of devDependencies, commit the lockfile, and rerun the build with a cleared cache.',11 },12 {13 value: 'out-of-memory',14 trigger: 'Build ran out of memory',15 content:16 'Node stops at the default heap limit on large bundles. Set NODE_OPTIONS to --max-old-space-size=4096 in the project settings, then split the largest entry point if it happens again.',17 },18 {19 value: 'missing-env',20 trigger: 'Environment variable was undefined at build time',21 content:22 'Variables scoped to Preview are not injected into Production builds. Check the scope column in Settings, Environment Variables, and remember that only NEXT_PUBLIC_ values reach the browser bundle.',23 },24];2526export function Multiple() {27 return (28 <Accordion type="multiple" className="w-full max-w-md">29 {causes.map((cause) => (30 <Accordion.Item key={cause.value} value={cause.value}>31 <Accordion.Trigger value={cause.value}>{cause.trigger}</Accordion.Trigger>32 <Accordion.Content value={cause.value} className="text-muted-foreground text-sm">33 {cause.content}34 </Accordion.Content>35 </Accordion.Item>36 ))}37 </Accordion>38 );39}
Sin valor por defecto
Puedes omitir
defaultValue para que el acordeón inicie completamente cerrado.1'use client';23import { Accordion } from '@/components/ui/accordion';45const sections = [6 {7 value: 'authentication',8 trigger: 'Authentication',9 content:10 'Send your secret key as a bearer token on every request. Keys are scoped per project, so a test key never touches live orders.',11 },12 {13 value: 'rate-limits',14 trigger: 'Rate limits',15 content:16 'The API allows 600 requests per minute per key. Every response carries the remaining budget in X-RateLimit-Remaining, and a 429 tells you how long to wait.',17 },18 {19 value: 'webhooks',20 trigger: 'Webhook retries',21 content:22 'A webhook that does not return a 2xx within 5 seconds is retried 8 times over 24 hours with exponential backoff. Reply fast and queue the work on your side.',23 },24 {25 value: 'versioning',26 trigger: 'Versioning',27 content:28 'Your account is pinned to the API version that was current when you signed up. Breaking changes ship under a new date version and never move you off yours.',29 },30];3132export function Collapsed() {33 return (34 <Accordion type="single" className="w-full max-w-md">35 {sections.map((section) => (36 <Accordion.Item key={section.value} value={section.value}>37 <Accordion.Trigger value={section.value}>{section.trigger}</Accordion.Trigger>38 <Accordion.Content value={section.value} className="text-muted-foreground text-sm">39 {section.content}40 </Accordion.Content>41 </Accordion.Item>42 ))}43 </Accordion>44 );45}
Referencia de API
Accordion
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
type | "single" | "multiple" | "single" | Controla si se puede abrir un solo ítem o varios simultáneamente. |
defaultValue | string | undefined | Ítem inicial abierto. |
children | React.ReactNode | — | Deben ser AccordionItem. |
className | string | — | Clases adicionales para el contenedor. |
headingLevel | 1 | 2 | 3 | 4 | 5 | 6 | 3 | Nivel del encabezado que envuelve cada trigger. Elegí el que encaje con la estructura de la página. |
Accordion.Item
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
value | string | — | ID único del ítem. Obligatorio. |
children | React.ReactNode | — | Debe incluir AccordionTrigger y AccordionContent. |
isOpen | boolean | Controlado interno | Indica si el ítem está abierto. |
onToggle | () => void | Controlado interno | Función para abrir/cerrar. |
className | string | — | Clases adicionales para el wrapper. |
Accordion.Trigger
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
children | React.ReactNode | — | Título o contenido del botón. |
isOpen | boolean | Controlado interno | Estado abierto/cerrado (rota el ícono). |
onToggle | () => void | Controlado interno | Acción al hacer click. |
className | string | — | Clases adicionales del botón. |
headingLevel | 1 | 2 | 3 | 4 | 5 | 6 | El de Accordion | Cambia el nivel de encabezado solo para este trigger. |
Accordion.Content
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
children | React.ReactNode | — | Contenido colapsable. |
isOpen | boolean | Controlado interno | Controla apertura y animación. |
className | string | — | Clases adicionales para el contenedor animado. |
¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.