ATOM

Tabs

Click a tab and the indicator slides under it while the panel swaps. Sectioned content without page jumps.

Preview

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

PropTipoDefaultRango / opcionesWhatHow
orientationselecthorizontal`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>
ClasePropósito
tabsroot
tabs--verticalmodifier
tabs__contentelement
tabs__indicatorelement
tabs__listelement
tabs__list--animatedelement
tabs__list--lineelement
tabs__triggerelement
tabs__trigger--activeelement
tabs__trigger--disabledelement
tabs__trigger-iconelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#0a0a0aprimary
allbordernone(unparsed)
verticalborder1pxstroke.hairline
allfg#0a0a0aforeground
allhover-fg#0a0a0aforeground

Animaciones

PropiedadDuraciónEasing
left0.5scubic-bezier(0.16
1
0.3
1)
top0.5scubic-bezier(0.16
1
0.3
1)
width0.5scubic-bezier(0.16
1
0.3
1)
height0.5scubic-bezier(0.16
1
0.3
1)
opacityvar(--duration-200)var(--easing-in-out)
colorvar(--duration-150)var(--easing-in-out)
background-colorvar(--duration-150)var(--easing-in-out)
box-shadowvar(--duration-150)var(--easing-in-out)
background-colorvar(--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

components-react
components/atoms/Tabs.tsx
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

Webflowtabs

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-visible on .tabs__trigger:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .tabs__list .tabs__triggercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .tabs--vertical .tabs__listcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .tabs--vertical .tabs__list:not(.tabs__list--line)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .tabs--vertical .tabs__list--linecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .tabs__list--animated .tabs__triggercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .tabs__list--animated .tabs__trigger--activecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .tabs__list--line .tabs__triggercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)

Tras pegar: Create component → nombre Atom / Tabs → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categorianavigation
Lectura...
Visitas...
Ayuda?Slack

Componente

Tabs

Source

components-react / cssDisponible via MCP: atom_uikit_source("tabs")
Abrir en Storybook