View as Markdown

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

php bin/console ux:install toggle-group --kit shadcn

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 app:

import { Controller } from &#039;@hotwired/stimulus&#039;;

export default class extends Controller {
    static values = {
        type: { type: String, default: &#039;multiple&#039; },
        labelSelector: { type: String, default: &#039;&#039; },
    };

    toggle(event) {
        if (this.typeValue !== &#039;single&#039;) {
            return;
        }

        const clicked = event.currentTarget;
        const toggles = this.element.querySelectorAll(&#039;[data-controller~=&quot;toggle&quot;]&#039;);

        for (const toggle of toggles) {
            if (toggle !== clicked) {
                const controller = this.application.getControllerForElementAndIdentifier(toggle, &#039;toggle&#039;);
                if (controller &amp;&amp; controller.pressedValue) {
                    controller.pressedValue = false;
                }
            }
        }

        if (this.labelSelectorValue) {
            const label = document.querySelector(this.labelSelectorValue);
            if (label) {
                const value = clicked.getAttribute(&#039;aria-label&#039;) || clicked.textContent.trim();
                label.textContent = value.toLowerCase();
            }
        }
    }
}
{# @prop variant &#039;default&#039;|&#039;outline&#039; The visual style variant. #}
{# @prop size &#039;default&#039;|&#039;sm&#039;|&#039;lg&#039; The toggle group size. #}
{# @prop type &#039;single&#039;|&#039;multiple&#039; Whether only one or multiple items can be active. #}
{# @prop spacing number Gap between toggle group items. #}
{# @prop orientation &#039;horizontal&#039;|&#039;vertical&#039; The layout direction. #}
{# @prop disabled boolean Whether all items in the group are disabled. #}
{# @block content The toggle items, typically multiple `ToggleGroup:Item` components. #}
{%- props variant = &#039;default&#039;, size = &#039;default&#039;, type = &#039;multiple&#039;, spacing = 2, orientation = &#039;horizontal&#039;, disabled = false -%}
{%- do provide(&#039;toggleGroup.variant&#039;, variant) -%}
{%- do provide(&#039;toggleGroup.size&#039;, size) -%}
{%- do provide(&#039;toggleGroup.spacing&#039;, spacing) -%}
{%- do provide(&#039;toggleGroup.orientation&#039;, orientation) -%}
{%- do provide(&#039;toggleGroup.disabled&#039;, disabled) -%}
&lt;div
    role=&quot;group&quot;
    style=&quot;--gap: {{ spacing }}&quot;
    class=&quot;{{ (&#039;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 &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    {{ attributes.defaults({
        &#039;data-slot&#039;: &#039;toggle-group&#039;,
        &#039;data-controller&#039;: &#039;toggle-group&#039;,
        &#039;data-toggle-group-type-value&#039;: type,
        &#039;data-variant&#039;: variant,
        &#039;data-size&#039;: size,
        &#039;data-spacing&#039;: spacing,
        &#039;data-orientation&#039;: orientation,
        &#039;data-horizontal&#039;: orientation == &#039;horizontal&#039;,
        &#039;data-vertical&#039;: orientation == &#039;vertical&#039;,
    }) }}
&gt;
    {%- block content %}{% endblock -%}
&lt;/div&gt;
{# @prop pressed boolean Whether the item is initially pressed. #}
{# @block content The toggle item label and/or icon. #}
{%- props pressed = false -%}
{%- set _toggleGroup_variant = inject(&#039;toggleGroup.variant&#039;, &#039;default&#039;) -%}
{%- set _toggleGroup_size = inject(&#039;toggleGroup.size&#039;, &#039;default&#039;) -%}
{%- set _toggleGroup_spacing = inject(&#039;toggleGroup.spacing&#039;, 2) -%}
{%- set _toggleGroup_disabled = inject(&#039;toggleGroup.disabled&#039;, false) -%}
{%- set style = html_cva(
    base: &quot;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#039;size-&#039;])]:size-4&quot;,
    variants: {
        variant: {
            default: &#039;bg-transparent&#039;,
            outline: &#039;border border-input bg-transparent hover:bg-muted&#039;,
        },
        size: {
            default: &#039;h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2&#039;,
            sm: &quot;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 [&amp;_svg:not([class*=&#039;size-&#039;])]:size-3.5&quot;,
            lg: &#039;h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2&#039;,
        },
    },
) -%}
&lt;button
    type=&quot;button&quot;
    class=&quot;{{ style.apply({variant: _toggleGroup_variant, size: _toggleGroup_size}, attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    {{ attributes.defaults({
        &#039;data-slot&#039;: &#039;toggle-group-item&#039;,
        &#039;data-controller&#039;: &#039;toggle&#039;,
        &#039;data-action&#039;: &#039;click-&gt;toggle#toggle click-&gt;toggle-group#toggle&#039;,
        &#039;data-toggle-pressed-value&#039;: pressed ? &#039;true&#039; : &#039;false&#039;,
        &#039;data-variant&#039;: _toggleGroup_variant,
        &#039;data-size&#039;: _toggleGroup_size,
        &#039;data-spacing&#039;: _toggleGroup_spacing,
        &#039;aria-pressed&#039;: pressed ? &#039;true&#039; : &#039;false&#039;,
        &#039;data-state&#039;: pressed ? &#039;on&#039; : &#039;off&#039;,
        disabled: _toggleGroup_disabled,
    }) }}
&gt;
    {%- block content %}{% endblock -%}
&lt;/button&gt;
import { Controller } from &#039;@hotwired/stimulus&#039;;

export default class extends Controller {
    static values = { pressed: Boolean };

    connect() {
        if (!this.hasPressedValue) {
            this.pressedValue = this.element.getAttribute(&#039;aria-pressed&#039;) === &#039;true&#039;;
        }

        this.updateState();
    }

    toggle() {
        this.pressedValue = !this.pressedValue;
    }

    pressedValueChanged() {
        this.updateState();
    }

    updateState() {
        const pressed = this.pressedValue;
        this.element.setAttribute(&#039;aria-pressed&#039;, String(pressed));
        this.element.dataset.state = pressed ? &#039;on&#039; : &#039;off&#039;;
    }
}
{# @prop variant &#039;default&#039;|&#039;outline&#039; The visual style variant. #}
{# @prop size &#039;default&#039;|&#039;sm&#039;|&#039;lg&#039; The toggle size. #}
{# @prop pressed boolean Whether the toggle is initially pressed. #}
{# @block content The toggle label and/or icon. #}
{%- props variant = &#039;default&#039;, size = &#039;default&#039;, pressed = false -%}
{%- set style = html_cva(
    base: &quot;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#039;size-&#039;])]:size-4&quot;,
    variants: {
        variant: {
            default: &#039;bg-transparent&#039;,
            outline: &#039;border border-input bg-transparent hover:bg-muted&#039;,
        },
        size: {
            default: &#039;h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2&#039;,
            sm: &quot;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 [&amp;_svg:not([class*=&#039;size-&#039;])]:size-3.5&quot;,
            lg: &#039;h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2&#039;,
        },
    },
    default_variant: {
        variant: &#039;default&#039;,
        size: &#039;default&#039;,
    },
) -%}
&lt;button
    type=&quot;button&quot;
    class=&quot;{{ style.apply({variant: variant, size: size}, attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    {{ attributes.defaults({
        &#039;data-slot&#039;: &#039;toggle&#039;,
        &#039;data-controller&#039;: &#039;toggle&#039;,
        &#039;data-action&#039;: &#039;click-&gt;toggle#toggle&#039;,
        &#039;aria-pressed&#039;: pressed ? &#039;true&#039; : &#039;false&#039;,
        &#039;data-state&#039;: pressed ? &#039;on&#039; : &#039;off&#039;,
    }) }}
&gt;
    {%- block content %}{% endblock -%}
&lt;/button&gt;

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

<twig:ToggleGroup>

Prop Type Default
variant 
'default'|'outline' 'default'
size 
'default'|'sm'|'lg' 'default'
type 
'single'|'multiple' 'multiple'
spacing 
number 2
orientation 
'horizontal'|'vertical' 'horizontal'
disabled 
boolean false
Block Description
content The toggle items, typically multiple ToggleGroup:Item components.

<twig:ToggleGroup:Item>

Prop Type Default
pressed 
boolean false
Block Description
content The toggle item label and/or icon.