ATOM
Components (registry)Actions

ToggleGroup

Segmented options where one (or more) stay pressed. View switches and compact filters.

Preview

Variants: defaultoutline (default: default)

Sizes: xssml (default: m)

Editorial

<!-- F12c editorial — non-derivable only. Review: Karen. -->

## Ejemplos

View switch (single select):

```tsx import { ToggleGroup, ToggleGroupItem } from '@/components/atoms/ToggleGroup';

<ToggleGroup type="single" value={view} onValueChange={setView} size="m"> <ToggleGroupItem value="list">List</ToggleGroupItem> <ToggleGroupItem value="grid">Grid</ToggleGroupItem> </ToggleGroup> ```

## Accesibilidad

- Each `ToggleGroupItem` needs a visible label or `aria-label`. - In forms, wrap with a labelled fieldset/`Field` so the group has an accessible name. - `type="single"` stores one string; `multiple` stores `string[]` — keep controlled state types in sync.

### Correcto

- role='group' en el container - role='radio' + aria-checked en cada item (type=single) - aria-disabled en items desactivados - Icon-only items necesitan aria-label (no hay texto visible) - focus-visible ring inset (outline-offset negativo) para no romper el layout

### Evitar

- No usar ToggleGroup como tabs de navegacion — usar Tabs para eso - No mezclar text items con icon-only items en el mismo grupo — inconsistencia visual - No usar type='multiple' con mas de 5-6 opciones — usar Checkbox list en su lugar

## Cuándo no usar

- Binary on/off for one setting → `Toggle` / `Checkbox`. - Navigation across routes → links or tabs with URLs, not a toggle group that only looks like nav.

## Criterio de uso

- Usa `type="single"` para cambiar una vista o modo y `type="multiple"` para filtros independientes. - Mantén etiquetas cortas pero inequívocas; si el grupo representa un campo del formulario, añade un nombre al conjunto. - En interfaces densas prefiere `m` o `s` y deja `animated` apagado cuando el cambio se repite con frecuencia.

## Gotchas

- El modo controlado requiere que el tipo del estado coincida: un string para `single`, un arreglo para `multiple`. - No lo uses como navegación si cada opción debe tener URL, historial o deep link. - **Nota**: Icon-only items son cuadrados (width = height). Se detecta automaticamente cuando children no es un string. - **Ojo**: El CSS standalone requiere JS para toggle de la clase --active. El componente React lo maneja internamente via Context. Para vanilla, toggle la clase manualmente en onClick. - **Nota**: La animacion de text swap reutiliza la misma estructura de Button: .button__label > .button__label-inner > .button__text.is--default + .button__text.is--hover. El ToggleGroupItem genera este markup cuando animated=\{true} y children es string.

Uso

import { ToggleGroup, ToggleGroupItem } from '@/components/atoms/ToggleGroup';

<ToggleGroup type="single" value={view} onValueChange={setView} size="m">
  <ToggleGroupItem value="list">List</ToggleGroupItem>
  <ToggleGroupItem value="grid">Grid</ToggleGroupItem>
</ToggleGroup>

Props

PropTipoDefaultRango / opcionesWhatHow
typeselectsingle`single`, `multiple`Selection model: one value or many.single for segmented controls; multiple for multi-filter toolbars. Default: single.
variantselectdefault`default`, `outline`Surface style of the group.default for primary toolbars; outline on busy backgrounds. Default: default.
sizeselectm`xs`, `s`, `m`, `l`Hit target scale for items.m default; xs/s for dense filters; l for marketing toggles. Default: m.
orientationselecthorizontal`horizontal`, `vertical`Layout axis of items.horizontal almost always; vertical only in narrow side rails. Default: horizontal.
animatedbooleanfalse`true` / `false`Motion on selection change.true for marketing; false in data-dense admin. Default: false.

Gotchas

  • react

    Controlled mode needs value + onValueChange; type=single stores one string, multiple stores string[].

  • a11y

    Each ToggleGroupItem needs a visible label or aria-label; group should sit in a labelled fieldset when used as a form control.

Anatomía CSS

<div class="toggle-group">
  <span class="toggle-group__item"></span>
  <span class="toggle-group__item--active"></span>
  <span class="toggle-group__item--disabled"></span>
  <span class="toggle-group__item--icon-only"></span>
  <span class="toggle-group__item--l"></span>
  <span class="toggle-group__item--m"></span>
  <span class="toggle-group__item--s"></span>
  <span class="toggle-group__item--xs"></span>
  <span class="toggle-group__item-icon"></span>
</div>
ClasePropósito
toggle-grouproot
toggle-group--outlinemodifier
toggle-group--verticalmodifier
toggle-group__itemelement
toggle-group__item--activeelement
toggle-group__item--disabledelement
toggle-group__item--icon-onlyelement
toggle-group__item--lelement
toggle-group__item--melement
toggle-group__item--selement
toggle-group__item--xselement
toggle-group__item-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
allfg#525252muted.foreground
allbordernone(unparsed)
allhover-bg#f5f5f5muted
allhover-fg#0a0a0aforeground
outlineborder1pxstroke.hairline

Animaciones

PropiedadDuraciónEasing
background-colorvar(--duration-150)var(--easing-in-out)
colorvar(--duration-150)var(--easing-in-out)
rotatevar(--duration-500)cubic-bezier(0.19
1
0.22
1)
rotate0.6s0.05s cubic-bezier(0.19
1
0.22
1)

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).

/* -------------------------------------------------------------------------
   Toggle Group

   Selectable button group. Single or multiple selection.
   Extends button-group layout with active state.

   Variants: default (ghost bg), outline (border)
   Sizes:    xs, s, m, l
   Parts:    .toggle-group, .toggle-group__item
   ------------------------------------------------------------------------- */

/* ---- Container ---- */

.toggle-group {
  display: inline-flex;
  align-items: stretch;
  gap: 4px;
  border-radius: 8px;
}

.toggle-group--vertical {
  flex-direction: column;
}

.toggle-group--outline {
  gap: 0;
}

/* ---- Item ---- */

.toggle-group__item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: none;
  border-radius: 8px;
  background: none;
  color: #525252;
  font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
  font-size: 12.8px;
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition:
    background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}

.toggle-group__item:hover {
  background-color: #f5f5f5;
  color: #0a0a0a;
}

.toggle-group__item:focus-visible {
  outline: 2px solid var(--focus-ring-color);
  outline-offset: calc(-1 * 2px);
  border-radius: 8px;
}

/* Active — follows button primary convention */

.toggle-group__item--active {
  background-color: #0a0a0a;
  color: #fafafa;
}

/* Disabled */

.toggle-group__item--disabled {
  color: #525252;
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

/* ---- Icon ---- */

.toggle-group__item-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
}

.toggle-group__item-icon svg {
  width: 100%;
  height: 100%;
}

/* ---- Sizes ---- */

.toggle-group__item--xs { height: 1.75rem; padding: 0 8px; }
.toggle-group__item--s  { height: 2rem; padding: 0 12px; }
.toggle-group__item--m  { height: 2.5rem; padding: 0 12px; }
.toggle-group__item--l  { height: 3rem; padding: 0 16px; }

/* Icon-only (square) */
.toggle-group__item--icon-only.toggle-group__item--xs { width: 1.75rem; padding: 0; }
.toggle-group__item--icon-only.toggle-group__item--s  { width: 2rem; padding: 0; }
.toggle-group__item--icon-only.toggle-group__item--m  { width: 2.5rem; padding: 0; }
.toggle-group__item--icon-only.toggle-group__item--l  { width: 3rem; padding: 0; }

/* ---- Outline variant ---- */

.toggle-group--outline .toggle-group__item {
  border: 1px solid #e5e5e5;
  border-radius: 0;
}

.toggle-group--outline:not(.toggle-group--vertical) .toggle-group__item + .toggle-group__item {
  margin-left: calc(-1 * 1px);
}

.toggle-group--outline:not(.toggle-group--vertical) .toggle-group__item:first-child {
  border-radius: 8px 0 0 8px;
}

.toggle-group--outline:not(.toggle-group--vertical) .toggle-group__item:last-child {
  border-radius: 0 8px 8px 0;
}

.toggle-group--outline.toggle-group--vertical .toggle-group__item + .toggle-group__item {
  margin-top: calc(-1 * 1px);
}

.toggle-group--outline.toggle-group--vertical .toggle-group__item:first-child {
  border-radius: 8px 8px 0 0;
}

.toggle-group--outline.toggle-group--vertical .toggle-group__item:last-child {
  border-radius: 0 0 8px 8px;
}

/* ---- Animation (opt-in) ---- */

.toggle-group__item-icon {
  transition: rotate 500ms cubic-bezier(0.19, 1, 0.22, 1);
}

@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  .toggle-group[data-toggle-group-animate] .toggle-group__item:hover:not(.toggle-group__item--disabled) {
    scale: 1.095;
  }

  .toggle-group[data-toggle-group-animate] .toggle-group__item:hover:not(.toggle-group__item--disabled) .toggle-group__item-icon {
    rotate: 360deg;
    transition: rotate 0.6s 0.05s cubic-bezier(0.19, 1, 0.22, 1);
  }
}

.toggle-group__item:active:not(.toggle-group__item--disabled) {
  scale: 0.925;
}

/* ---- Reduced motion ---- */

@media (prefers-reduced-motion: reduce) {
  .toggle-group__item,
  .toggle-group__item-icon {
    transition-duration: 0ms;
  }

  .toggle-group__item:active {
    scale: 1;
  }
}

Codigo fuente

components-react
components/atoms/ToggleGroup.tsx
import {
  type ReactNode,
  useState,
  useCallback,
  createContext,
  useContext,
} from 'react';

function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ');
}

/* ---- Context ---- */

type ToggleGroupContextValue = {
  type: 'single' | 'multiple';
  value: string[];
  onToggle: (val: string) => void;
  variant: 'default' | 'outline';
  size: 'xs' | 's' | 'm' | 'l';
  animated: boolean;
};

const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);

function useToggleGroup() {
  const ctx = useContext(ToggleGroupContext);
  if (!ctx) throw new Error('ToggleGroupItem must be used within <ToggleGroup>');
  return ctx;
}

/* ---- Root ---- */

export type ToggleGroupProps = {
  type?: 'single' | 'multiple';
  value?: string | string[];
  onValueChange?: (value: string | string[]) => void;
  variant?: 'default' | 'outline';
  size?: 'xs' | 's' | 'm' | 'l';
  orientation?: 'horizontal' | 'vertical';
  animated?: boolean;
  children: ReactNode;
  className?: string;
};

export function ToggleGroup({
  type = 'single',
  value: controlledValue,
  onValueChange,
  variant = 'default',
  size = 'm',
  orientation = 'horizontal',
  animated = false,
  children,
  className,
}: ToggleGroupProps) {
  const [internalValue, setInternalValue] = useState<string[]>([]);

  const value = controlledValue
    ? Array.isArray(controlledValue) ? controlledValue : [controlledValue]
    : internalValue;

  const onToggle = useCallback(
    (val: string) => {
      let next: string[];
      if (type === 'single') {
        next = value.includes(val) ? [] : [val];
      } else {
        next = value.includes(val) ? value.filter((v) => v !== val) : [...value, val];
      }

      if (onValueChange) {
        onValueChange(type === 'single' ? (next[0] ?? '') : next);
      } else {
        setInternalValue(next);
      }
    },
    [type, value, onValueChange],
  );

  return (
    <ToggleGroupContext.Provider value={{ type, value, onToggle, variant, size, animated }}>
      <div
        role="group"
        {...(animated ? { 'data-toggle-group-animate': '' } : {})}
        className={cn(
          'toggle-group',
          orientation === 'vertical' && 'toggle-group--vertical',
          variant === 'outline' && 'toggle-group--outline',
          className,
        )}
      >
        {children}
      </div>
    </ToggleGroupContext.Provider>
  );
}

/* ---- Item ---- */

export type ToggleGroupItemProps = {
  value: string;
  disabled?: boolean;
  children: ReactNode;
  className?: string;
};

export function ToggleGroupItem({ value: itemValue, disabled = false, children, className }: ToggleGroupItemProps) {
  const { value, onToggle, size, animated } = useToggleGroup();
  const isActive = value.includes(itemValue);
  const isIconOnly = typeof children !== 'string';
  const useTextSwap = animated && !isIconOnly;

  const content = useTextSwap ? (
    <span className="button__label">
      <span className="button__label-inner">
        <span className="button__text is--default" data-button-text="">{children}</span>
        <span className="button__text is--hover" data-button-text="" aria-hidden="true">{children}</span>
      </span>
    </span>
  ) : children;

  return (
    <button
      type="button"
      role="radio"
      aria-checked={isActive}
      aria-disabled={disabled || undefined}
      disabled={disabled}
      {...(useTextSwap ? { 'data-toggle-group-animate': '' } : {})}
      className={cn(
        'toggle-group__item',
        `toggle-group__item--${size}`,
        isActive && 'toggle-group__item--active',
        disabled && 'toggle-group__item--disabled',
        isIconOnly && 'toggle-group__item--icon-only',
        className,
      )}
      onClick={() => {
        if (!disabled) onToggle(itemValue);
      }}
    >
      {content}
    </button>
  );
}

Webflow

Webflowtoggle-group

Pega en el Designer como application/json, luego convierte a Component (Atom / ToggleGroup) 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 .toggle-group__item:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .toggle-group__item-icon svgcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .toggle-group__item--icon-only.toggle-group__item--xscompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .toggle-group__item--icon-only.toggle-group__item--scompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .toggle-group__item--icon-only.toggle-group__item--mcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .toggle-group__item--icon-only.toggle-group__item--lcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .toggle-group--outline .toggle-group__itemcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .toggle-group--outline:not(.toggle-group--vertical) .toggle-group__item + .toggle-group__itemcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)

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

On this page

Detalles

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

Componente

ToggleGroup

Source

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