ToggleGroup
Segmented options where one (or more) stay pressed. View switches and compact filters.
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
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| type | select | single | `single`, `multiple` | Selection model: one value or many. | single for segmented controls; multiple for multi-filter toolbars. Default: single. |
| variant | select | default | `default`, `outline` | Surface style of the group. | default for primary toolbars; outline on busy backgrounds. Default: default. |
| size | select | m | `xs`, `s`, `m`, `l` | Hit target scale for items. | m default; xs/s for dense filters; l for marketing toggles. Default: m. |
| orientation | select | horizontal | `horizontal`, `vertical` | Layout axis of items. | horizontal almost always; vertical only in narrow side rails. Default: horizontal. |
| animated | boolean | false | `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>
| Clase | Propósito |
|---|---|
toggle-group | root |
toggle-group--outline | modifier |
toggle-group--vertical | modifier |
toggle-group__item | element |
toggle-group__item--active | element |
toggle-group__item--disabled | element |
toggle-group__item--icon-only | element |
toggle-group__item--l | element |
toggle-group__item--m | element |
toggle-group__item--s | element |
toggle-group__item--xs | element |
toggle-group__item-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 | fg | #525252 | muted.foreground |
| all | border | none | (unparsed) |
| all | hover-bg | #f5f5f5 | muted |
| all | hover-fg | #0a0a0a | foreground |
| outline | border | 1px | stroke.hairline |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--duration-150) | var(--easing-in-out) |
color | var(--duration-150) | var(--easing-in-out) |
rotate | var(--duration-500) | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
rotate | 0.6s | 0.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
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
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-visibleon.toggle-group__item:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.toggle-group__item-icon svg— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.toggle-group__item--icon-only.toggle-group__item--xs— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.toggle-group__item--icon-only.toggle-group__item--s— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.toggle-group__item--icon-only.toggle-group__item--m— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.toggle-group__item--icon-only.toggle-group__item--l— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.toggle-group--outline .toggle-group__item— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.toggle-group--outline:not(.toggle-group--vertical) .toggle-group__item + .toggle-group__item— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
Tras pegar: Create component → nombre Atom / ToggleGroup → Publish.