Collapsible
Ocultar contenido secundario. Reducir ruido visual. Mejorar enfoque.
Build settings
Framework preset detected: Vite. These values are used on every deployment.
1'use client';23import { Collapsible } from '@/components/ui/collapsible';45const advanced = [6 { label: 'Build command', value: 'pnpm build' },7 { label: 'Output directory', value: 'dist' },8 { label: 'Install command', value: 'pnpm install --frozen-lockfile' },9 { label: 'Node version', value: '22.x' },10];1112export function Default() {13 return (14 <div className="border-border bg-card w-full max-w-md space-y-4 rounded-xl border p-4">15 <div>16 <p className="text-sm font-medium">Build settings</p>17 <p className="text-muted-foreground text-xs">18 Framework preset detected: Vite. These values are used on every deployment.19 </p>20 </div>2122 <Collapsible>23 <Collapsible.Trigger>24 <span className="text-sm font-medium">Advanced options</span>25 </Collapsible.Trigger>26 <Collapsible.Content>27 <dl className="divide-border divide-y pt-3">28 {advanced.map((setting) => (29 <div key={setting.label} className="flex items-center justify-between gap-3 py-2">30 <dt className="text-muted-foreground text-xs">{setting.label}</dt>31 <dd className="bg-muted rounded px-2 py-1 font-mono text-xs">{setting.value}</dd>32 </div>33 ))}34 </dl>35 </Collapsible.Content>36 </Collapsible>37 </div>38 );39}
Instalación
Copia y pega el siguiente código en tu proyecto.
1'use client';23import { cva } from 'class-variance-authority';4import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';5import * as React from 'react';6import { cn } from '../lib/cn';7import { collapse, collapseInner, springs, still, tap } from '../lib/motion';8import { assignRef } from '../lib/overlay';9import { useControllableState } from '../lib/use-controllable-state';1011type IconProps = React.SVGProps<SVGSVGElement> & {12 size?: number | string;13};1415function ChevronDownIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {16 return (17 <svg18 xmlns="http://www.w3.org/2000/svg"19 width={size}20 height={size}21 viewBox="0 0 24 24"22 fill="none"23 stroke="currentColor"24 strokeWidth={strokeWidth}25 strokeLinecap="round"26 strokeLinejoin="round"27 aria-hidden="true"28 {...props}29 >30 <path d="m6 9 6 6 6-6" />31 </svg>32 );33}3435const COLLAPSIBLE_CONTENT_STYLE = { willChange: 'height, opacity' } as const;3637const REDUCED_COLLAPSE = {38 closed: { height: 0, opacity: 0, transition: still },39 open: { height: 'auto', opacity: 1, transition: still },40} as const;4142const collapsibleVariants = cva('', {43 variants: {44 variant: {45 default: 'rounded-lg',46 bordered: 'rounded-lg border border-border',47 card: 'rounded-lg border border-border bg-card',48 },49 },50 defaultVariants: {51 variant: 'default',52 },53});5455const collapsibleTriggerVariants = cva(56 [57 'flex w-full items-center justify-between',58 'transition-all duration-200',59 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ring-offset-background focus-visible:ring-offset-2',60 'disabled:pointer-events-none disabled:opacity-40',61 ].join(' '),62 {63 variants: {64 variant: {65 default: 'hover:opacity-80',66 bordered: 'p-4 hover:bg-muted/50',67 card: 'p-4 hover:bg-muted/50',68 },69 },70 defaultVariants: {71 variant: 'default',72 },73 },74);7576const collapsibleContentVariants = cva('overflow-hidden', {77 variants: {78 variant: {79 default: '',80 bordered: 'px-4 pb-4',81 card: 'px-4 pb-4',82 },83 },84 defaultVariants: {85 variant: 'default',86 },87});8889interface CollapsibleContextValue {90 isOpen: boolean;91 setIsOpen: (open: boolean) => void;92 disabled?: boolean;93 variant?: 'default' | 'bordered' | 'card';94 id: string;95}9697interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {98 defaultOpen?: boolean;99 open?: boolean;100 onOpenChange?: (open: boolean) => void;101 disabled?: boolean;102 variant?: 'default' | 'bordered' | 'card';103}104105interface CollapsibleTriggerProps extends Omit<HTMLMotionProps<'button'>, 'children'> {106 showChevron?: boolean;107 chevronIcon?: React.ReactNode;108 chevronPosition?: 'left' | 'right';109 asChild?: boolean;110 children?: React.ReactNode;111}112113interface CollapsibleContentProps extends Omit<HTMLMotionProps<'div'>, 'children'> {114 forceMount?: boolean;115 children?: React.ReactNode;116}117118const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null);119120const useCollapsibleContext = (): CollapsibleContextValue => {121 const context = React.use(CollapsibleContext);122 if (!context) {123 throw new Error('Collapsible components must be used within Collapsible');124 }125 return context;126};127128const CollapsibleRoot = ({129 className,130 defaultOpen = false,131 open: controlledOpen,132 onOpenChange,133 disabled = false,134 variant = 'default',135 children,136 ref,137 ...props138}: CollapsibleProps & { ref?: React.Ref<HTMLDivElement> }) => {139 const [isOpen, setOpen] = useControllableState({140 value: controlledOpen,141 defaultValue: defaultOpen,142 onChange: onOpenChange,143 });144145 const setIsOpen = React.useCallback(146 (open: boolean) => {147 if (!disabled) setOpen(open);148 },149 [disabled, setOpen],150 );151152 const id = React.useId();153154 const contextValue = React.useMemo<CollapsibleContextValue>(155 () => ({ isOpen, setIsOpen, disabled, variant, id }),156 [isOpen, setIsOpen, disabled, variant, id],157 );158159 return (160 <CollapsibleContext value={contextValue}>161 <div162 ref={ref}163 data-state={isOpen ? 'open' : 'closed'}164 data-disabled={disabled ? '' : undefined}165 className={cn(collapsibleVariants({ variant }), className)}166 {...props}167 >168 {children}169 </div>170 </CollapsibleContext>171 );172};173174CollapsibleRoot.displayName = 'Collapsible';175176const CollapsibleTrigger = ({177 className,178 showChevron = true,179 chevronIcon,180 chevronPosition = 'right',181 asChild = false,182 children,183 onClick,184 ref,185 ...props186}: CollapsibleTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {187 const { isOpen, setIsOpen, disabled, variant, id } = useCollapsibleContext();188 const shouldReduceMotion = useReducedMotion();189190 const handleClick = React.useCallback(191 (e: React.MouseEvent<HTMLButtonElement>) => {192 if (!disabled) {193 setIsOpen(!isOpen);194 onClick?.(e);195 }196 },197 [disabled, isOpen, setIsOpen, onClick],198 );199200 const chevron = chevronIcon ?? <ChevronDownIcon className="h-4 w-4 shrink-0" size={16} />;201202 if (asChild && React.isValidElement<React.ButtonHTMLAttributes<HTMLElement>>(children)) {203 const childProps = children.props as React.ButtonHTMLAttributes<HTMLElement> & {204 ref?: React.Ref<HTMLElement>;205 };206 const childOnClick = childProps.onClick;207 const childRef = childProps.ref;208 return React.cloneElement(children, {209 ref:210 ref || childRef211 ? (node: HTMLButtonElement | null) => {212 assignRef(ref, node);213 assignRef(childRef, node);214 }215 : undefined,216 type: childProps.type ?? (children.type === 'button' ? 'button' : undefined),217 id: childProps.id ?? `${id}-trigger`,218 onClick: (e: React.MouseEvent<HTMLElement>) => {219 childOnClick?.(e);220 if (e.defaultPrevented) return;221 handleClick(e as React.MouseEvent<HTMLButtonElement>);222 },223 'data-state': isOpen ? 'open' : 'closed',224 'aria-expanded': isOpen,225 'aria-controls': `${id}-content`,226 disabled,227 } as React.ButtonHTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLButtonElement> });228 }229230 return (231 <motion.button232 ref={ref}233 type="button"234 id={`${id}-trigger`}235 onClick={handleClick}236 disabled={disabled}237 data-state={isOpen ? 'open' : 'closed'}238 aria-expanded={isOpen}239 aria-controls={`${id}-content`}240 whileTap={!disabled && !shouldReduceMotion ? tap : undefined}241 transition={springs.snappy}242 className={cn(collapsibleTriggerVariants({ variant }), className)}243 {...props}244 >245 {showChevron && chevronPosition === 'left' && (246 <motion.span247 aria-hidden="true"248 animate={{ rotate: isOpen ? 90 : 0 }}249 transition={shouldReduceMotion ? still : springs.snappy}250 className="mr-2"251 >252 {chevron}253 </motion.span>254 )}255 <span className="flex-1 text-left">{children}</span>256 {showChevron && chevronPosition === 'right' && (257 <motion.span258 aria-hidden="true"259 animate={{ rotate: isOpen ? 180 : 0 }}260 transition={shouldReduceMotion ? still : springs.snappy}261 className="ml-2"262 >263 {chevron}264 </motion.span>265 )}266 </motion.button>267 );268};269270CollapsibleTrigger.displayName = 'CollapsibleTrigger';271272const CollapsibleContent = ({273 className,274 forceMount = false,275 children,276 onAnimationComplete,277 ref,278 ...props279}: CollapsibleContentProps & { ref?: React.Ref<HTMLDivElement> }) => {280 const { isOpen, variant, id } = useCollapsibleContext();281 const shouldReduceMotion = useReducedMotion();282 const [closeSettled, setCloseSettled] = React.useState(!isOpen);283 const state = isOpen ? 'open' : 'closed';284285 if (isOpen && closeSettled) setCloseSettled(false);286287 return (288 <AnimatePresence initial={false} mode="sync">289 {(isOpen || forceMount) && (290 <motion.div291 ref={ref}292 id={`${id}-content`}293 role="region"294 aria-labelledby={`${id}-trigger`}295 data-state={state}296 hidden={forceMount && !isOpen && closeSettled ? true : undefined}297 inert={!isOpen || undefined}298 variants={shouldReduceMotion ? REDUCED_COLLAPSE : collapse}299 initial="closed"300 animate={state}301 exit="closed"302 onAnimationComplete={(definition) => {303 if (definition === 'closed' && !isOpen) setCloseSettled(true);304 onAnimationComplete?.(definition);305 }}306 style={shouldReduceMotion ? undefined : COLLAPSIBLE_CONTENT_STYLE}307 className={cn(collapsibleContentVariants({ variant }), className)}308 {...props}309 >310 <motion.div311 variants={shouldReduceMotion ? undefined : collapseInner}312 initial="closed"313 animate={state}314 exit="closed"315 >316 {children}317 </motion.div>318 </motion.div>319 )}320 </AnimatePresence>321 );322};323324CollapsibleContent.displayName = 'CollapsibleContent';325326const Collapsible = Object.assign(CollapsibleRoot, {327 Trigger: CollapsibleTrigger,328 Content: CollapsibleContent,329});330331export { Collapsible, collapsibleContentVariants, collapsibleTriggerVariants, collapsibleVariants };332333export type { CollapsibleContentProps, CollapsibleProps, CollapsibleTriggerProps };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { Collapsible } from '@/components/ui/collapsible';
1<Collapsible>2 <Collapsible.Trigger>¿Puedo usarlo en mi proyecto?</Collapsible.Trigger>3 <Collapsible.Content>Sí. Es gratuito y de código abierto.</Collapsible.Content>4</Collapsible>
Composición
Usa la siguiente composición para construir un
Collapsible:Collapsible├── Collapsible.Trigger└── Collapsible.Content
Variantes
Bordered
Collapsible con borde y esquinas redondeadas.
- Edge cache hits
- 286 GB
- Origin responses
- 104 GB
- Image optimization
- 22 GB
1'use client';23import { Collapsible } from '@/components/ui/collapsible';45const usage = [6 {7 title: 'Bandwidth',8 total: '412 GB of 1 TB',9 lines: [10 { label: 'Edge cache hits', value: '286 GB' },11 { label: 'Origin responses', value: '104 GB' },12 { label: 'Image optimization', value: '22 GB' },13 ],14 },15 {16 title: 'Build minutes',17 total: '318 of 400',18 lines: [19 { label: 'Production builds', value: '96 min' },20 { label: 'Preview builds', value: '204 min' },21 { label: 'Cancelled builds', value: '18 min' },22 ],23 },24 {25 title: 'Edge requests',26 total: '2.4M of 5M',27 lines: [28 { label: 'API routes', value: '1.7M' },29 { label: 'Static assets', value: '0.6M' },30 { label: 'Redirects', value: '0.1M' },31 ],32 },33];3435export function Bordered() {36 return (37 <div className="w-full max-w-md space-y-3">38 {usage.map((group, index) => (39 <Collapsible key={group.title} variant="bordered" defaultOpen={index === 0}>40 <Collapsible.Trigger>41 <span className="flex flex-col items-start">42 <span className="text-sm font-medium">{group.title}</span>43 <span className="text-muted-foreground text-xs">{group.total}</span>44 </span>45 </Collapsible.Trigger>46 <Collapsible.Content>47 <dl className="divide-border divide-y pt-1">48 {group.lines.map((line) => (49 <div key={line.label} className="flex items-center justify-between gap-3 py-2">50 <dt className="text-muted-foreground text-xs">{line.label}</dt>51 <dd className="text-xs font-medium tabular-nums">{line.value}</dd>52 </div>53 ))}54 </dl>55 </Collapsible.Content>56 </Collapsible>57 ))}58 </div>59 );60}
Card
Collapsible con estilo de tarjeta incluyendo borde, fondo y sombra.
- 09:12Investigating slow responses on /v1/orders.
- 09:41Traced to a saturated read replica, traffic shifted to iad1.
- 10:05Latency back to normal, replica replaced.
1'use client';23import { Badge } from '@/components/ui/badge';4import { Collapsible } from '@/components/ui/collapsible';56const incidents = [7 {8 title: 'Elevated API latency in eu-central',9 date: 'Mar 14',10 status: 'Resolved',11 tone: 'success',12 updates: [13 { time: '09:12', text: 'Investigating slow responses on /v1/orders.' },14 { time: '09:41', text: 'Traced to a saturated read replica, traffic shifted to iad1.' },15 { time: '10:05', text: 'Latency back to normal, replica replaced.' },16 ],17 },18 {19 title: 'Webhook deliveries delayed',20 date: 'Mar 12',21 status: 'Monitoring',22 tone: 'warning',23 updates: [24 { time: '17:20', text: 'Delivery queue backed up behind a retry storm.' },25 { time: '17:55', text: 'Queue drained, watching retry rates for the next few hours.' },26 ],27 },28] as const;2930export function Card() {31 return (32 <div className="w-full max-w-md space-y-3">33 {incidents.map((incident, index) => (34 <Collapsible key={incident.title} variant="card" defaultOpen={index === 0}>35 <Collapsible.Trigger>36 <span className="flex flex-col items-start gap-1">37 <span className="flex items-center gap-2">38 <Badge variant={incident.tone}>{incident.status}</Badge>39 <span className="text-muted-foreground text-xs">{incident.date}</span>40 </span>41 <span className="text-left text-sm font-medium">{incident.title}</span>42 </span>43 </Collapsible.Trigger>44 <Collapsible.Content>45 <ol className="border-border ml-1 space-y-3 border-l pt-1 pl-4">46 {incident.updates.map((update) => (47 <li key={update.time} className="text-sm">48 <span className="text-muted-foreground mr-2 font-mono text-xs">49 {update.time}50 </span>51 {update.text}52 </li>53 ))}54 </ol>55 </Collapsible.Content>56 </Collapsible>57 ))}58 </div>59 );60}
Referencia de API
Collapsible
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
defaultOpen | boolean | false | Estado inicial abierto (modo no controlado). |
open | boolean | — | Estado abierto controlado. |
onOpenChange | (open: boolean) => void | — | Callback que se ejecuta cuando cambia el estado abierto. |
disabled | boolean | false | Desactiva la interacción. |
variant | 'default' | 'bordered' | 'card' | 'default' | Estilo visual del contenedor collapsible. |
className | string | — | Clases adicionales. |
children | React.ReactNode | — | Contenido dentro del collapsible. |
Collapsible.Trigger
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
showChevron | boolean | true | Muestra el ícono de chevron. |
chevronIcon | React.ReactNode | — | Ícono personalizado para el chevron. |
chevronPosition | 'left' | 'right' | 'right' | Posición del ícono. |
asChild | boolean | false | Permite usar un elemento personalizado como trigger. |
children | React.ReactNode | — | Texto o contenido del trigger. |
onClick | (event: React.MouseEvent) => void | — | Manejador de click (combinado con la lógica de abrir/cerrar). |
className | string | — | Clases adicionales. |
Collapsible.Content
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
forceMount | boolean | false | Mantiene el contenido en el DOM aunque esté cerrado (útil para SSR). Queda oculto e inerte hasta que se abre. |
children | React.ReactNode | — | Contenido a mostrar u ocultar. |
className | string | — | Clases adicionales. |
¿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.