Tabs
Click a tab and the indicator slides under it while the panel swaps. Sectioned content without page jumps.
Variants: defaultline (default: default)
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Sectioned settings:
```tsx import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/atoms/Tabs';
<Tabs defaultValue="general" orientation="horizontal"> <TabsList> <TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="billing">Billing</TabsTrigger> </TabsList> <TabsContent value="general">Profile settings</TabsContent> <TabsContent value="billing">Invoices</TabsContent> </Tabs> ```
## Accesibilidad
- Arrow keys move along `orientation`; keep one selected tab and matching `TabsContent` values. - Uncontrolled: `defaultValue`. Controlled: `value` + `onValueChange`.
## Cuándo no usar
- Multi-step linear progress → `Stepper` / `ProgressNav`. - Route-level navigation that should change the URL → real links / router nav.
## Criterio de uso
- Usa tabs para cambiar entre vistas relacionadas sin abandonar el contexto ni provocar un salto de página. - Mantén los valores de `TabsTrigger` y `TabsContent` idénticos; cada tab debe tener un panel correspondiente. - Usa orientación horizontal para secciones de contenido y vertical para settings con una navegación lateral.
## Gotchas
- El foco y las flechas deben seguir el eje declarado; no dependas sólo del click para cambiar de panel. - Si el estado debe ser compartible por URL, indexable o conservarse al volver atrás, usa navegación real en lugar de tabs locales.
Uso
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/atoms/Tabs';
<Tabs defaultValue="general" orientation="horizontal">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="general">Profile settings</TabsContent>
<TabsContent value="billing">Invoices</TabsContent>
</Tabs>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| orientation | select | horizontal | `horizontal`, `vertical` | Axis of the tab list and arrow-key navigation. | horizontal for page sections; vertical for settings side-tabs. Default: horizontal. |
Gotchas
- react
Uncontrolled: defaultValue. Controlled: value + onValueChange. Each TabsTrigger value must match a TabsContent value.
- a11y
Arrow keys move selection along orientation; keep one selected tab and associate panels via matching value strings.
Anatomía CSS
<div class="tabs"> <span class="tabs__content"></span> <span class="tabs__indicator"></span> <span class="tabs__list"></span> <span class="tabs__list--animated"></span> <span class="tabs__list--line"></span> <span class="tabs__trigger"></span> <span class="tabs__trigger--active"></span> <span class="tabs__trigger--disabled"></span> <span class="tabs__trigger-icon"></span> </div>
| Clase | Propósito |
|---|---|
tabs | root |
tabs--vertical | modifier |
tabs__content | element |
tabs__indicator | element |
tabs__list | element |
tabs__list--animated | element |
tabs__list--line | element |
tabs__trigger | element |
tabs__trigger--active | element |
tabs__trigger--disabled | element |
tabs__trigger-icon | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #0a0a0a | primary |
| all | border | none | (unparsed) |
| vertical | border | 1px | stroke.hairline |
| all | fg | #0a0a0a | foreground |
| all | hover-fg | #0a0a0a | foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
left | 0.5s | cubic-bezier(0.16 |
1 | | |
0.3 | | |
1) | | |
top | 0.5s | cubic-bezier(0.16 |
1 | | |
0.3 | | |
1) | | |
width | 0.5s | cubic-bezier(0.16 |
1 | | |
0.3 | | |
1) | | |
height | 0.5s | cubic-bezier(0.16 |
1 | | |
0.3 | | |
1) | | |
opacity | var(--duration-200) | var(--easing-in-out) |
color | var(--duration-150) | var(--easing-in-out) |
background-color | var(--duration-150) | var(--easing-in-out) |
box-shadow | var(--duration-150) | var(--easing-in-out) |
background-color | var(--duration-150) | var(--easing-in-out) |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Tabs
Tabbed content switcher. Horizontal or vertical.
Variants: default (pill bg), line (underline indicator).
Parts: .tabs, .tabs__list, .tabs__trigger, .tabs__content
------------------------------------------------------------------------- */
/* ---- Root ---- */
.tabs {
display: flex;
flex-direction: column;
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
}
.tabs--vertical {
flex-direction: row;
gap: 16px;
}
/* ---- List ---- */
.tabs__list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 3px;
background-color: #f5f5f5;
border-radius: 12px;
}
.tabs__list .tabs__trigger {
flex: 1;
text-align: center;
}
/* Line variant: no bg, bottom border */
.tabs__list--line {
background: none;
border-radius: 0;
padding: 0;
gap: 0;
border-bottom: 1px solid #e5e5e5;
}
/* Vertical list */
.tabs--vertical .tabs__list {
flex-direction: column;
align-items: stretch;
}
.tabs--vertical .tabs__list:not(.tabs__list--line) {
border-radius: 12px;
}
.tabs--vertical .tabs__list--line {
border-bottom: none;
border-right: 1px solid #e5e5e5;
}
/* ---- Animated indicator ---- */
.tabs__list--animated {
position: relative;
}
.tabs__indicator {
position: absolute;
border-radius: 8px;
background-color: #fafafa;
box-shadow: 0 1px rgb(0 0 0 / 0.05);
pointer-events: none;
z-index: 0;
transition:
left 0.5s cubic-bezier(0.16, 1, 0.3, 1),
top 0.5s cubic-bezier(0.16, 1, 0.3, 1),
width 0.5s cubic-bezier(0.16, 1, 0.3, 1),
height 0.5s cubic-bezier(0.16, 1, 0.3, 1),
opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.tabs__list--animated .tabs__trigger {
position: relative;
z-index: 1;
}
.tabs__list--animated .tabs__trigger--active {
background: none;
box-shadow: none;
}
/* ---- Trigger ---- */
.tabs__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: none;
color: color-mix(in srgb, #0a0a0a 55%, #525252);
font-family: inherit;
font-size: 12.8px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
color 150ms cubic-bezier(0.4, 0, 0.2, 1),
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.tabs__trigger:hover {
color: #0a0a0a;
}
.tabs__trigger:focus-visible {
outline: 2px solid var(--focus-ring-color);
outline-offset: -2px;
}
/* Active (default variant) */
.tabs__trigger--active {
background-color: #fafafa;
color: #0a0a0a;
box-shadow: 0 1px rgb(0 0 0 / 0.05);
}
/* Disabled */
.tabs__trigger--disabled {
color: #525252;
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ---- Line variant triggers ---- */
.tabs__list--line .tabs__trigger {
border-radius: 0;
padding: 8px 16px;
position: relative;
}
.tabs__list--line .tabs__trigger::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: transparent;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.tabs__list--line .tabs__trigger--active {
background: none;
box-shadow: none;
color: #0a0a0a;
}
.tabs__list--line .tabs__trigger--active::after {
background-color: #0a0a0a;
}
/* Line vertical */
.tabs--vertical .tabs__list--line .tabs__trigger::after {
bottom: auto;
left: auto;
right: -1px;
top: 0;
width: 2px;
height: 100%;
}
/* ---- Trigger icon ---- */
.tabs__trigger-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1em;
height: 1em;
}
.tabs__trigger-icon svg {
width: 100%;
height: 100%;
}
/* ---- Content ---- */
.tabs__content {
padding-top: 16px;
}
.tabs--vertical .tabs__content {
padding-top: 0;
flex: 1;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.tabs__trigger,
.tabs__list--line .tabs__trigger::after {
transition-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, type KeyboardEvent, type CSSProperties, useState, useCallback, useEffect, useRef, createContext, useContext, } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } /* ---- Context ---- */ type TabsContextValue = { value: string; setValue: (v: string) => void; orientation: 'horizontal' | 'vertical'; }; const TabsContext = createContext<TabsContextValue | null>(null); function useTabs() { const ctx = useContext(TabsContext); if (!ctx) throw new Error('Tabs components must be used within <Tabs>'); return ctx; } /* ---- Root ---- */ export type TabsProps = { defaultValue?: string; value?: string; onValueChange?: (value: string) => void; orientation?: 'horizontal' | 'vertical'; children: ReactNode; className?: string; }; export function Tabs({ defaultValue = '', value: controlledValue, onValueChange, orientation = 'horizontal', children, className, }: TabsProps) { const [internalValue, setInternalValue] = useState(defaultValue); const value = controlledValue ?? internalValue; const setValue = useCallback( (v: string) => { onValueChange ? onValueChange(v) : setInternalValue(v); }, [onValueChange], ); return ( <TabsContext.Provider value={{ value, setValue, orientation }}> <div className={cn('tabs', orientation === 'vertical' && 'tabs--vertical', className)}> {children} </div> </TabsContext.Provider> ); } /* ---- List ---- */ export type TabsListProps = { variant?: 'default' | 'line'; animated?: boolean; children: ReactNode; className?: string; }; export function TabsList({ variant = 'default', animated = false, children, className }: TabsListProps) { const { value } = useTabs(); const listRef = useRef<HTMLDivElement>(null); const [indicatorStyle, setIndicatorStyle] = useState<CSSProperties>({ opacity: 0 }); useEffect(() => { if (!animated || !listRef.current) return; const list = listRef.current; const active = list.querySelector<HTMLButtonElement>('[role="tab"][aria-selected="true"]'); if (!active) { setIndicatorStyle((prev) => ({ ...prev, opacity: 0 })); return; } const listRect = list.getBoundingClientRect(); const activeRect = active.getBoundingClientRect(); setIndicatorStyle({ opacity: 1, left: `${activeRect.left - listRect.left}px`, top: `${activeRect.top - listRect.top}px`, width: `${activeRect.width}px`, height: `${activeRect.height}px`, }); }, [animated, value]); return ( <div ref={listRef} role="tablist" className={cn( 'tabs__list', variant === 'line' && 'tabs__list--line', animated && 'tabs__list--animated', className, )} > {animated && <div className="tabs__indicator" style={indicatorStyle} />} {children} </div> ); } /* ---- Trigger ---- */ export type TabsTriggerProps = { value: string; disabled?: boolean; children: ReactNode; className?: string; }; export function TabsTrigger({ value: triggerValue, disabled = false, children, className }: TabsTriggerProps) { const { value, setValue, orientation } = useTabs(); const isActive = value === triggerValue; const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => { const el = e.currentTarget; const list = el.parentElement; if (!list) return; const triggers = Array.from(list.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])')); const idx = triggers.indexOf(el); let next = -1; const isHorizontal = orientation === 'horizontal'; if ((isHorizontal && e.key === 'ArrowRight') || (!isHorizontal && e.key === 'ArrowDown')) { next = idx < triggers.length - 1 ? idx + 1 : 0; } else if ((isHorizontal && e.key === 'ArrowLeft') || (!isHorizontal && e.key === 'ArrowUp')) { next = idx > 0 ? idx - 1 : triggers.length - 1; } if (next >= 0) { e.preventDefault(); triggers[next].focus(); triggers[next].click(); } }; return ( <button type="button" role="tab" aria-selected={isActive} tabIndex={isActive ? 0 : -1} disabled={disabled} className={cn( 'tabs__trigger', isActive && 'tabs__trigger--active', disabled && 'tabs__trigger--disabled', className, )} onClick={() => { if (!disabled) setValue(triggerValue); }} onKeyDown={handleKeyDown} > {children} </button> ); } /* ---- Content ---- */ export type TabsContentProps = { value: string; children: ReactNode; className?: string; }; export function TabsContent({ value: contentValue, children, className }: TabsContentProps) { const { value } = useTabs(); if (value !== contentValue) return null; return ( <div role="tabpanel" tabIndex={0} className={cn('tabs__content', className)}> {children} </div> ); }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Tabs) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.
Setup del sitio (una vez)
Custom Code → Head:
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">
Unsupported (no silencioso)
:focus-visibleon.tabs__trigger:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.tabs__list .tabs__trigger— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.tabs--vertical .tabs__list— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.tabs--vertical .tabs__list:not(.tabs__list--line)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.tabs--vertical .tabs__list--line— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.tabs__list--animated .tabs__trigger— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.tabs__list--animated .tabs__trigger--active— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.tabs__list--line .tabs__trigger— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
Tras pegar: Create component → nombre Atom / Tabs → Publish.