Tabs
Organizar contenido en vistas. Evitar recargas. Mejorar navegación.
storefront-apiReady
Deployment dpl_9fk2ax was promoted to production 12 minutes ago.
- Build time
- 42s
- Output size
- 218 kB
- Region
- iad1
- Commit
- 9fk2ax
1'use client';23import { Badge } from '@/components/ui/badge';4import { Card } from '@/components/ui/card';5import { Tabs } from '@/components/ui/tabs';67const summary = [8 { label: 'Build time', value: '42s' },9 { label: 'Output size', value: '218 kB' },10 { label: 'Region', value: 'iad1' },11 { label: 'Commit', value: '9fk2ax' },12];1314const buildLog = [15 '14:02:11 Cloning github.com/acme/storefront-api (branch: main)',16 '14:02:14 Running "pnpm install --frozen-lockfile"',17 '14:02:38 Running "pnpm build"',18 '14:02:52 Uploading build output (218 kB)',19 '14:02:53 Deployment ready',20];2122const envVars = [23 { key: 'DATABASE_URL', value: 'postgres://••••', scope: 'Production' },24 { key: 'STRIPE_SECRET_KEY', value: 'sk_live_••••4f2a', scope: 'Production' },25 { key: 'NEXT_PUBLIC_API_URL', value: 'https://api.acme.dev', scope: 'All' },26];2728export function Default() {29 return (30 <Tabs defaultValue="overview" variant="default" size="sm" className="w-full max-w-lg">31 <Tabs.List>32 <Tabs.Trigger value="overview">Overview</Tabs.Trigger>33 <Tabs.Trigger value="logs">Build log</Tabs.Trigger>34 <Tabs.Trigger value="env">Environment</Tabs.Trigger>35 </Tabs.List>3637 <Tabs.Content value="overview">38 <Card className="border-border shadow-sm">39 <Card.Header>40 <Card.Title className="flex items-center gap-2">41 storefront-api42 <Badge variant="success">Ready</Badge>43 </Card.Title>44 <Card.Description>45 Deployment dpl_9fk2ax was promoted to production 12 minutes ago.46 </Card.Description>47 </Card.Header>48 <Card.Content>49 <dl className="grid grid-cols-2 gap-3 text-sm">50 {summary.map((item) => (51 <div key={item.label}>52 <dt className="text-muted-foreground text-xs">{item.label}</dt>53 <dd className="font-medium">{item.value}</dd>54 </div>55 ))}56 </dl>57 </Card.Content>58 </Card>59 </Tabs.Content>6061 <Tabs.Content value="logs">62 <Card className="border-border shadow-sm">63 <Card.Header>64 <Card.Title>Build log</Card.Title>65 <Card.Description>Last 5 lines of the production build.</Card.Description>66 </Card.Header>67 <Card.Content>68 <pre className="bg-muted text-muted-foreground overflow-x-auto rounded-md p-3 font-mono text-xs">69 {buildLog.join('\n')}70 </pre>71 </Card.Content>72 </Card>73 </Tabs.Content>7475 <Tabs.Content value="env">76 <Card className="border-border shadow-sm">77 <Card.Header>78 <Card.Title>Environment variables</Card.Title>79 <Card.Description>80 Values are encrypted and hidden after you save them.81 </Card.Description>82 </Card.Header>83 <Card.Content>84 <ul className="divide-border divide-y text-sm">85 {envVars.map((variable) => (86 <li key={variable.key} className="flex items-center justify-between gap-3 py-2">87 <span className="font-mono text-xs font-medium">{variable.key}</span>88 <span className="flex items-center gap-2">89 <span className="text-muted-foreground font-mono text-xs">90 {variable.value}91 </span>92 <Badge variant="outline">{variable.scope}</Badge>93 </span>94 </li>95 ))}96 </ul>97 </Card.Content>98 </Card>99 </Tabs.Content>100 </Tabs>101 );102}
Instalación
Copia y pega el siguiente código en tu proyecto.
1'use client';23import { cva, type VariantProps } from 'class-variance-authority';4import { motion, useReducedMotion } from 'motion/react';5import * as React from 'react';6import { cn } from '../lib/cn';7import { springs, still } from '../lib/motion';8import { useControllableState } from '../lib/use-controllable-state';910const tabsListVariants = cva(11 'inline-flex max-w-full rounded-md p-0.5 text-muted-foreground w-full sm:w-auto overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',12 {13 variants: {14 variant: {15 default: 'bg-muted/60 border border-border/40',16 outline: 'border border-border bg-transparent',17 underline: 'bg-transparent border-b border-border rounded-none p-0 justify-start w-full',18 ghost: 'bg-transparent p-0 gap-1',19 unstyled: '',20 },21 size: {22 default: 'h-9',23 sm: 'h-8',24 lg: 'h-11',25 },26 },27 },28);2930const tabsTriggerVariants = cva(31 'relative inline-flex shrink-0 items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 z-10 cursor-pointer',32 {33 variants: {34 variant: {35 default: 'text-muted-foreground data-[state=active]:text-foreground',36 outline: 'text-muted-foreground data-[state=active]:text-foreground',37 underline:38 'text-muted-foreground hover:text-foreground data-[state=active]:text-foreground rounded-none bg-transparent px-4 pb-3 pt-2',39 ghost:40 'text-muted-foreground hover:text-foreground data-[state=active]:text-foreground hover:bg-muted/50 rounded-md',41 },42 size: {43 default: 'text-sm',44 sm: 'text-xs',45 lg: 'text-base px-5',46 },47 },48 defaultVariants: {49 variant: 'default',50 size: 'default',51 },52 },53);5455type TabsOrientation = 'horizontal' | 'vertical';5657interface TabsContextValue {58 activeTab: string;59 setActiveTab: (value: string) => void;60 variant: NonNullable<TabsProps['variant']>;61 size: NonNullable<TabsProps['size']>;62 layoutId: string;63 mountedPanels: ReadonlySet<string>;64 registerPanel: (value: string) => () => void;65}6667interface TabsListContextValue {68 orientation: TabsOrientation;69 fallbackTab: string | null;70}7172const TabsContext = React.createContext<TabsContextValue | null>(null);73const TabsListContext = React.createContext<TabsListContextValue>({74 orientation: 'horizontal',75 fallbackTab: null,76});7778function useTabsContext() {79 const context = React.use(TabsContext);80 if (!context) throw new Error('Tabs components must be used within Tabs');81 return context;82}8384function toDomId(value: string) {85 return value.replace(86 /[^A-Za-z0-9-]/g,87 (char) => `_${char.charCodeAt(0).toString(16).padStart(4, '0')}`,88 );89}9091function getTriggers(list: Element) {92 return Array.from(list.querySelectorAll<HTMLButtonElement>('[role="tab"]')).filter(93 (trigger) => trigger.closest('[role="tablist"]') === list,94 );95}9697interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {98 defaultValue?: string;99 value?: string;100 onValueChange?: (value: string) => void;101 variant?: 'default' | 'outline' | 'underline' | 'ghost';102 size?: 'default' | 'sm' | 'lg';103}104105const TabsRoot = ({106 className,107 defaultValue,108 value: controlledValue,109 onValueChange,110 variant = 'default',111 size = 'default',112 children,113 ref,114 ...props115}: TabsProps & { ref?: React.Ref<HTMLDivElement> }) => {116 const [activeTab, setActiveTab] = useControllableState({117 value: controlledValue,118 defaultValue: defaultValue || '',119 onChange: onValueChange,120 });121 const layoutId = React.useId();122 const [mountedPanels, setMountedPanels] = React.useState<ReadonlySet<string>>(() => new Set());123124 const registerPanel = React.useCallback((value: string) => {125 setMountedPanels((previous) => (previous.has(value) ? previous : new Set(previous).add(value)));126 return () => {127 setMountedPanels((previous) => {128 if (!previous.has(value)) return previous;129 const next = new Set(previous);130 next.delete(value);131 return next;132 });133 };134 }, []);135136 const contextValue = React.useMemo(137 () => ({ activeTab, setActiveTab, variant, size, layoutId, mountedPanels, registerPanel }),138 [activeTab, setActiveTab, variant, size, layoutId, mountedPanels, registerPanel],139 );140141 return (142 <TabsContext value={contextValue}>143 <div ref={ref} className={cn('w-full', className)} {...props}>144 {children}145 </div>146 </TabsContext>147 );148};149TabsRoot.displayName = 'Tabs';150151interface TabsListProps152 extends153 Omit<154 React.HTMLAttributes<HTMLDivElement>,155 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'156 >,157 VariantProps<typeof tabsListVariants> {158 orientation?: TabsOrientation;159}160161const TabsList = ({162 className,163 variant,164 size,165 orientation = 'horizontal',166 children,167 ref,168 ...props169}: TabsListProps & { ref?: React.Ref<HTMLDivElement> }) => {170 const { activeTab, variant: contextVariant, size: contextSize } = useTabsContext();171 const finalVariant = variant || contextVariant;172 const listRef = React.useRef<HTMLDivElement | null>(null);173 const [fallbackTab, setFallbackTab] = React.useState<string | null>(null);174175 React.useLayoutEffect(() => {176 const list = listRef.current;177 if (!list) return;178 const enabled = getTriggers(list).filter((trigger) => !trigger.disabled);179 const hasActive = enabled.some((trigger) => trigger.getAttribute('aria-selected') === 'true');180 setFallbackTab(hasActive ? null : (enabled[0]?.dataset.value ?? null));181 }, [activeTab, children]);182183 const mergedRef = React.useCallback(184 (node: HTMLDivElement | null) => {185 listRef.current = node;186 if (typeof ref === 'function') ref(node);187 else if (ref) ref.current = node;188 },189 [ref],190 );191192 const listContext = React.useMemo(193 () => ({ orientation, fallbackTab }),194 [orientation, fallbackTab],195 );196197 return (198 <TabsListContext value={listContext}>199 <motion.div200 ref={mergedRef}201 role="tablist"202 aria-orientation={orientation}203 layoutScroll204 className={cn(205 tabsListVariants({ variant: finalVariant, size: size || contextSize }),206 className,207 )}208 {...props}209 >210 {children}211 </motion.div>212 </TabsListContext>213 );214};215TabsList.displayName = 'TabsList';216217interface TabsTriggerProps218 extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof tabsTriggerVariants> {219 value: string;220 children: React.ReactNode;221}222223const TabsTrigger = ({224 className,225 value,226 children,227 variant,228 size,229 onClick,230 onKeyDown,231 ref,232 ...props233}: TabsTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {234 const {235 activeTab,236 setActiveTab,237 variant: contextVariant,238 size: contextSize,239 layoutId,240 mountedPanels,241 } = useTabsContext();242 const { orientation, fallbackTab } = React.use(TabsListContext);243 const isActive = activeTab === value;244 const finalVariant = variant || contextVariant;245 const finalSize = size || contextSize;246 const shouldReduceMotion = useReducedMotion();247 const domValue = toDomId(value);248 const hasPanel = isActive || mountedPanels.has(value);249250 const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {251 onClick?.(e);252 if (e.defaultPrevented) return;253 setActiveTab(value);254 };255256 const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {257 onKeyDown?.(e);258 if (e.defaultPrevented) return;259 const current = e.currentTarget;260 const list = current.closest('[role="tablist"]');261 if (!list) return;262263 const triggers = getTriggers(list).filter((trigger) => !trigger.disabled);264 const currentIndex = triggers.indexOf(current);265 if (currentIndex === -1) return;266267 const isRtl = orientation === 'horizontal' && getComputedStyle(list).direction === 'rtl';268 const nextKey = orientation === 'vertical' ? 'ArrowDown' : isRtl ? 'ArrowLeft' : 'ArrowRight';269 const previousKey = orientation === 'vertical' ? 'ArrowUp' : isRtl ? 'ArrowRight' : 'ArrowLeft';270271 let newIndex: number;272 if (e.key === nextKey) {273 newIndex = (currentIndex + 1) % triggers.length;274 } else if (e.key === previousKey) {275 newIndex = (currentIndex - 1 + triggers.length) % triggers.length;276 } else if (e.key === 'Home') {277 newIndex = 0;278 } else if (e.key === 'End') {279 newIndex = triggers.length - 1;280 } else {281 return;282 }283284 e.preventDefault();285 if (newIndex !== currentIndex) {286 triggers[newIndex]?.focus();287 triggers[newIndex]?.click();288 }289 };290291 return (292 <button293 ref={ref}294 type="button"295 role="tab"296 id={`${layoutId}-trigger-${domValue}`}297 aria-selected={isActive}298 aria-controls={hasPanel ? `${layoutId}-content-${domValue}` : undefined}299 tabIndex={isActive || fallbackTab === value ? 0 : -1}300 data-state={isActive ? 'active' : 'inactive'}301 data-value={value}302 className={cn(tabsTriggerVariants({ variant: finalVariant, size: finalSize }), className)}303 {...props}304 onClick={handleClick}305 onKeyDown={handleKeyDown}306 >307 <span className="inherit relative z-20">{children}</span>308 {isActive && (309 <motion.div310 layoutId={`${layoutId}-indicator`}311 className={cn(312 'absolute inset-0 z-10',313 finalVariant === 'underline'314 ? 'bg-foreground top-auto bottom-0 h-[1.5px]'315 : 'bg-background dark:bg-input border-border/30 rounded-sm border shadow-sm',316 )}317 transition={shouldReduceMotion ? still : springs.smooth}318 />319 )}320 </button>321 );322};323TabsTrigger.displayName = 'TabsTrigger';324325interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {326 value: string;327 forceMount?: boolean;328}329330const TabsContent = ({331 className,332 value,333 forceMount = false,334 children,335 ref,336 ...props337}: TabsContentProps & { ref?: React.Ref<HTMLDivElement> }) => {338 const { activeTab, layoutId, registerPanel } = useTabsContext();339 const isActive = activeTab === value;340 const domValue = toDomId(value);341342 React.useLayoutEffect(() => {343 if (!forceMount) return;344 return registerPanel(value);345 }, [forceMount, registerPanel, value]);346347 if (!isActive && !forceMount) return null;348349 return (350 <div351 ref={ref}352 role="tabpanel"353 id={`${layoutId}-content-${domValue}`}354 aria-labelledby={`${layoutId}-trigger-${domValue}`}355 tabIndex={0}356 hidden={!isActive}357 data-state={isActive ? 'active' : 'inactive'}358 className={cn(359 'ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',360 className,361 )}362 {...props}363 >364 {children}365 </div>366 );367};368TabsContent.displayName = 'TabsContent';369370const Tabs = Object.assign(TabsRoot, {371 List: TabsList,372 Trigger: TabsTrigger,373 Content: TabsContent,374});375376export { Tabs };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { Tabs } from '@/components/ui/tabs';
1<Tabs defaultValue="account">2 <Tabs.List>3 <Tabs.Trigger value="account">Cuenta</Tabs.Trigger>4 <Tabs.Trigger value="password">Contraseña</Tabs.Trigger>5 </Tabs.List>6 <Tabs.Content value="account">Contenido de configuración de cuenta</Tabs.Content>7 <Tabs.Content value="password">Contenido de configuración de contraseña</Tabs.Content>8</Tabs>
Composición
Usa la siguiente composición para construir un
Tabs:Tabs├── Tabs.List│ └── Tabs.Trigger└── Tabs.Content
Variantes
Vertical
Account
The details your teammates see.
- Full name
- Lucia Mendez
- lucia@acmestudio.dev
- Role
- Workspace owner
- Member since
- Aug 2023
1'use client';23import { Badge } from '@/components/ui/badge';4import { Card } from '@/components/ui/card';5import { Tabs } from '@/components/ui/tabs';67const profile = [8 { label: 'Full name', value: 'Lucia Mendez' },9 { label: 'Email', value: 'lucia@acmestudio.dev' },10 { label: 'Role', value: 'Workspace owner' },11 { label: 'Member since', value: 'Aug 2023' },12];1314const sessions = [15 { device: 'MacBook Pro, Chrome', location: 'Lisbon, PT', seen: 'Active now' },16 { device: 'iPhone 15, Safari', location: 'Lisbon, PT', seen: '2h ago' },17 { device: 'Windows, Firefox', location: 'Berlin, DE', seen: 'Mar 09' },18];1920const alerts = [21 { title: 'Deploy failures', detail: 'Email and Slack', enabled: true },22 { title: 'Weekly usage report', detail: 'Email, Mondays at 9:00', enabled: true },23 { title: 'Comment mentions', detail: 'Slack only', enabled: false },24];2526export function Vertical() {27 return (28 <Tabs defaultValue="account" className="flex w-full flex-col gap-4 sm:flex-row">29 <Tabs.List className="flex h-auto w-full flex-col justify-start sm:w-44">30 <Tabs.Trigger value="account" className="w-full justify-start">31 Account32 </Tabs.Trigger>33 <Tabs.Trigger value="security" className="w-full justify-start">34 Security35 </Tabs.Trigger>36 <Tabs.Trigger value="notifications" className="w-full justify-start">37 Notifications38 </Tabs.Trigger>39 </Tabs.List>4041 <div className="min-w-0 flex-1">42 <Tabs.Content value="account" className="mt-0">43 <Card>44 <Card.Header>45 <Card.Title>Account</Card.Title>46 <Card.Description>The details your teammates see.</Card.Description>47 </Card.Header>48 <Card.Content>49 <dl className="divide-border divide-y text-sm">50 {profile.map((field) => (51 <div key={field.label} className="flex justify-between gap-3 py-2">52 <dt className="text-muted-foreground">{field.label}</dt>53 <dd className="font-medium">{field.value}</dd>54 </div>55 ))}56 </dl>57 </Card.Content>58 </Card>59 </Tabs.Content>6061 <Tabs.Content value="security" className="mt-0">62 <Card>63 <Card.Header>64 <Card.Title className="flex items-center gap-2">65 Security66 <Badge variant="success">2FA on</Badge>67 </Card.Title>68 <Card.Description>Password last changed on Feb 02.</Card.Description>69 </Card.Header>70 <Card.Content>71 <ul className="divide-border divide-y text-sm">72 {sessions.map((session) => (73 <li key={session.device} className="flex justify-between gap-3 py-2">74 <span>75 <span className="block font-medium">{session.device}</span>76 <span className="text-muted-foreground text-xs">{session.location}</span>77 </span>78 <span className="text-muted-foreground text-xs">{session.seen}</span>79 </li>80 ))}81 </ul>82 </Card.Content>83 </Card>84 </Tabs.Content>8586 <Tabs.Content value="notifications" className="mt-0">87 <Card>88 <Card.Header>89 <Card.Title>Notifications</Card.Title>90 <Card.Description>What we send you, and where.</Card.Description>91 </Card.Header>92 <Card.Content>93 <ul className="divide-border divide-y text-sm">94 {alerts.map((alert) => (95 <li key={alert.title} className="flex justify-between gap-3 py-2">96 <span>97 <span className="block font-medium">{alert.title}</span>98 <span className="text-muted-foreground text-xs">{alert.detail}</span>99 </span>100 <Badge variant={alert.enabled ? 'success' : 'secondary'}>101 {alert.enabled ? 'On' : 'Off'}102 </Badge>103 </li>104 ))}105 </ul>106 </Card.Content>107 </Card>108 </Tabs.Content>109 </div>110 </Tabs>111 );112}
Características
- Múltiples variantes - Estilos predeterminado, contorno, subrayado y fantasma
- Indicador animado - Indicador de pestaña activa con deslizamiento suave
- Navegación por teclado - Soporte completo de teclado
- Transiciones suaves - Cambio de contenido animado
Referencia de API
Tabs
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
defaultValue | string | - | Pestaña activa por defecto |
value | string | - | Pestaña activa controlada |
onValueChange | (value: string) => void | - | Callback cuando cambia la pestaña |
variant | 'default' | 'outline' | 'underline' | 'ghost' | 'default' | Estilo visual |
size | 'default' | 'sm' | 'lg' | 'default' | Tamaño de pestaña |
className | string | - | Clases CSS adicionales |
Tabs.List
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
variant | 'default' | 'outline' | 'underline' | 'ghost' | Hereda | Sobrescribir variante |
size | 'default' | 'sm' | 'lg' | Hereda | Sobrescribir tamaño |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Qué flechas mueven entre pestañas: izquierda y derecha, o arriba y abajo |
className | string | - | Clases CSS adicionales |
Tabs.Trigger
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
value | string | - | Identificador único de pestaña |
variant | 'default' | 'outline' | 'underline' | 'ghost' | Hereda | Sobrescribir variante |
size | 'default' | 'sm' | 'lg' | Hereda | Sobrescribir tamaño |
className | string | - | Clases CSS adicionales |
Tabs.Content
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
value | string | - | Identificador de pestaña coincidente |
forceMount | boolean | false | Mantiene el panel en el DOM mientras está oculto, así conserva su estado al cambiar de pestaña |
className | string | - | Clases CSS 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.