Toggle Group
A set of two-state buttons that can be toggled on or off.
Loading...
<twig:ToggleGroup variant="outline" type="multiple">
<twig:ToggleGroup:Item aria-label="Toggle bold">
<twig:ux:icon name="lucide:bold" />
</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle italic">
<twig:ux:icon name="lucide:italic" />
</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle strikethrough">
<twig:ux:icon name="lucide:underline" />
</twig:ToggleGroup:Item>
</twig:ToggleGroup>
Installation
Note
Available since UX Toolkit 3.0.
bin/console ux:install toggle-group --kit shadcn
That's it!
Install the following Composer dependencies:
composer require twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra:^1.0.0 symfony/ux-twig-component:^3.1
Copy the following file(s) into your Symfony app:
assets/controllers/toggle_group_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = {
type: { type: String, default: 'multiple' },
labelSelector: { type: String, default: '' },
};
toggle(event) {
if (this.typeValue !== 'single') {
return;
}
const clicked = event.currentTarget;
const toggles = this.element.querySelectorAll('[data-controller~="toggle"]');
for (const toggle of toggles) {
if (toggle !== clicked) {
const controller = this.application.getControllerForElementAndIdentifier(toggle, 'toggle');
if (controller && controller.pressedValue) {
controller.pressedValue = false;
}
}
}
if (this.labelSelectorValue) {
const label = document.querySelector(this.labelSelectorValue);
if (label) {
const value = clicked.getAttribute('aria-label') || clicked.textContent.trim();
label.textContent = value.toLowerCase();
}
}
}
}
templates/components/ToggleGroup.html.twig
{# @prop variant 'default'|'outline' The visual style variant. Defaults to `default` #}
{# @prop size 'default'|'sm'|'lg' The toggle group size. Defaults to `default` #}
{# @prop type 'single'|'multiple' Whether only one or multiple items can be active. Defaults to `multiple` #}
{# @prop spacing number Gap between toggle group items. Defaults to `2` #}
{# @prop orientation 'horizontal'|'vertical' The layout direction. Defaults to `horizontal` #}
{# @prop disabled boolean Whether all items in the group are disabled. Defaults to `false` #}
{# @block content The toggle items, typically multiple `ToggleGroup:Item` components #}
{%- props variant = 'default', size = 'default', type = 'multiple', spacing = 2, orientation = 'horizontal', disabled = false -%}
{%- do provide('toggleGroup.variant', variant) -%}
{%- do provide('toggleGroup.size', size) -%}
{%- do provide('toggleGroup.spacing', spacing) -%}
{%- do provide('toggleGroup.orientation', orientation) -%}
{%- do provide('toggleGroup.disabled', disabled) -%}
<div
role="group"
style="--gap: {{ spacing }}"
class="{{ ('group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes.defaults({
'data-slot': 'toggle-group',
'data-controller': 'toggle-group',
'data-toggle-group-type-value': type,
'data-variant': variant,
'data-size': size,
'data-spacing': spacing,
'data-orientation': orientation,
'data-horizontal': orientation == 'horizontal',
'data-vertical': orientation == 'vertical',
}) }}
>
{%- block content %}{% endblock -%}
</div>
templates/components/ToggleGroup/Item.html.twig
{# @prop pressed boolean Whether the item is initially pressed. Defaults to `false` #}
{# @block content The toggle item label and/or icon #}
{%- props pressed = false -%}
{%- set _toggleGroup_variant = inject('toggleGroup.variant', 'default') -%}
{%- set _toggleGroup_size = inject('toggleGroup.size', 'default') -%}
{%- set _toggleGroup_spacing = inject('toggleGroup.spacing', 2) -%}
{%- set _toggleGroup_disabled = inject('toggleGroup.disabled', false) -%}
{%- set style = html_cva(
base: "shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-transparent',
outline: 'border border-input bg-transparent hover:bg-muted',
},
size: {
default: 'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
},
},
) -%}
<button
type="button"
class="{{ style.apply({variant: _toggleGroup_variant, size: _toggleGroup_size}, attributes.render('class'))|tailwind_merge }}"
{{ attributes.defaults({
'data-slot': 'toggle-group-item',
'data-controller': 'toggle',
'data-action': 'click->toggle#toggle click->toggle-group#toggle',
'data-toggle-pressed-value': pressed ? 'true' : 'false',
'data-variant': _toggleGroup_variant,
'data-size': _toggleGroup_size,
'data-spacing': _toggleGroup_spacing,
'aria-pressed': pressed ? 'true' : 'false',
'data-state': pressed ? 'on' : 'off',
disabled: _toggleGroup_disabled,
}) }}
>
{%- block content %}{% endblock -%}
</button>
assets/controllers/toggle_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = { pressed: Boolean };
connect() {
if (!this.hasPressedValue) {
this.pressedValue = this.element.getAttribute('aria-pressed') === 'true';
}
this.updateState();
}
toggle() {
this.pressedValue = !this.pressedValue;
}
pressedValueChanged() {
this.updateState();
}
updateState() {
const pressed = this.pressedValue;
this.element.setAttribute('aria-pressed', String(pressed));
this.element.dataset.state = pressed ? 'on' : 'off';
}
}
templates/components/Toggle.html.twig
{# @prop variant 'default'|'outline' The visual style variant. Defaults to `default` #}
{# @prop size 'default'|'sm'|'lg' The toggle size. Defaults to `default` #}
{# @prop pressed boolean Whether the toggle is initially pressed. Defaults to `false` #}
{# @block content The toggle label and/or icon #}
{%- props variant = 'default', size = 'default', pressed = false -%}
{%- set style = html_cva(
base: "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-transparent',
outline: 'border border-input bg-transparent hover:bg-muted',
},
size: {
default: 'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
},
},
default_variant: {
variant: 'default',
size: 'default',
},
) -%}
<button
type="button"
class="{{ style.apply({variant: variant, size: size}, attributes.render('class'))|tailwind_merge }}"
{{ attributes.defaults({
'data-slot': 'toggle',
'data-controller': 'toggle',
'data-action': 'click->toggle#toggle',
'aria-pressed': pressed ? 'true' : 'false',
'data-state': pressed ? 'on' : 'off',
}) }}
>
{%- block content %}{% endblock -%}
</button>
Happy coding!
Usage
<twig:ToggleGroup type="single">
<twig:ToggleGroup:Item value="a">A</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item value="b">B</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item value="c">C</twig:ToggleGroup:Item>
</twig:ToggleGroup>
Examples
Outline
Loading...
<twig:ToggleGroup variant="outline" type="single">
<twig:ToggleGroup:Item aria-label="Toggle all" pressed>All</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle missed">Missed</twig:ToggleGroup:Item>
</twig:ToggleGroup>
Size
Loading...
<div class="flex flex-col gap-4">
<twig:ToggleGroup variant="outline" size="sm" type="single">
<twig:ToggleGroup:Item aria-label="Toggle top" pressed>Top</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle bottom">Bottom</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle left">Left</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle right">Right</twig:ToggleGroup:Item>
</twig:ToggleGroup>
<twig:ToggleGroup variant="outline" type="single">
<twig:ToggleGroup:Item aria-label="Toggle top" pressed>Top</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle bottom">Bottom</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle left">Left</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle right">Right</twig:ToggleGroup:Item>
</twig:ToggleGroup>
</div>
Spacing
Loading...
<twig:ToggleGroup variant="outline" size="sm" type="single" spacing="2">
<twig:ToggleGroup:Item aria-label="Toggle top" pressed>Top</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle bottom">Bottom</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle left">Left</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle right">Right</twig:ToggleGroup:Item>
</twig:ToggleGroup>
Vertical
Loading...
<twig:ToggleGroup type="multiple" orientation="vertical" spacing="1">
<twig:ToggleGroup:Item aria-label="Toggle bold" pressed>
<twig:ux:icon name="lucide:bold" />
</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle italic" pressed>
<twig:ux:icon name="lucide:italic" />
</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle underline">
<twig:ux:icon name="lucide:underline" />
</twig:ToggleGroup:Item>
</twig:ToggleGroup>
Disabled
Loading...
<twig:ToggleGroup disabled type="multiple">
<twig:ToggleGroup:Item aria-label="Toggle bold">
<twig:ux:icon name="lucide:bold" />
</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle italic">
<twig:ux:icon name="lucide:italic" />
</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="Toggle underline">
<twig:ux:icon name="lucide:underline" />
</twig:ToggleGroup:Item>
</twig:ToggleGroup>
RTL
To enable RTL support, set the dir="rtl" attribute on the root element.
Loading...
<div class="flex flex-col items-center gap-4">
{# Arabic #}
<twig:ToggleGroup variant="outline" type="single" dir="rtl">
<twig:ToggleGroup:Item aria-label="قائمة" pressed>قائمة</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="شبكة">شبكة</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="بطاقات">بطاقات</twig:ToggleGroup:Item>
</twig:ToggleGroup>
{# Hebrew #}
<twig:ToggleGroup variant="outline" type="single" dir="rtl">
<twig:ToggleGroup:Item aria-label="רשימה" pressed>רשימה</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="רשת">רשת</twig:ToggleGroup:Item>
<twig:ToggleGroup:Item aria-label="כרטיסים">כרטיסים</twig:ToggleGroup:Item>
</twig:ToggleGroup>
</div>
API Reference
Component ToggleGroup
| Prop | Type | Description |
|---|---|---|
variant |
'default'|'outline' |
The visual style variant. Defaults to default |
size |
'default'|'sm'|'lg' |
The toggle group size. Defaults to default |
type |
'single'|'multiple' |
Whether only one or multiple items can be active. Defaults to multiple |
spacing |
number |
Gap between toggle group items. Defaults to 2 |
orientation |
'horizontal'|'vertical' |
The layout direction. Defaults to horizontal |
disabled |
boolean |
Whether all items in the group are disabled. Defaults to false |
| Block | Description |
|---|---|
content |
The toggle items, typically multiple ToggleGroup:Item components |
Component ToggleGroup:Item
| Prop | Type | Description |
|---|---|---|
pressed |
boolean |
Whether the item is initially pressed. Defaults to false |
| Block | Description |
|---|---|
content |
The toggle item label and/or icon |