Toggle Group

A set of two-state buttons that can be toggled on or off.

Loading...
<twig:ToggleGroup variant="outline">
    <twig:ToggleGroup:Item aria-label="Toggle bold">
        <twig:ux:icon name="lucide:bold" class="size-4" />
    </twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle italic">
        <twig:ux:icon name="lucide:italic" class="size-4" />
    </twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle underline">
        <twig:ux:icon name="lucide:underline" class="size-4" />
    </twig:ToggleGroup:Item>
</twig:ToggleGroup>

Installation

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

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` #}
{# @block content The toggle items, typically multiple `ToggleGroup:Item` components #}
{%- props variant = 'default', size = 'default', type = 'multiple' -%}
{%- set _toggle_group_variant = variant -%}
{%- set _toggle_group_size = size -%}
{%- set style = html_cva(
    base: 'group/toggle-group flex w-fit items-center rounded-lg',
    variants: {
        variant: {
            default: '',
            outline: 'shadow-xs',
        },
    },
) -%}
<div
    role="group"
    class="{{ style.apply({variant: variant}, 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,
    }) }}
>
    {%- 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 _item_variant = _toggle_group_variant ?? 'default' -%}
{%- set _item_size = _toggle_group_size ?? 'default' -%}
{%- set style = html_cva(
    base: "inline-flex items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-accent data-[state=active]:text-accent-foreground aria-pressed:bg-accent aria-pressed:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 first:rounded-s-lg last:rounded-e-lg",
    variants: {
        variant: {
            default: '',
            outline: 'border border-input shadow-none not-first:-ml-px',
        },
        size: {
            default: 'h-9 min-w-9 px-2',
            sm: 'h-8 min-w-8 px-1.5',
            lg: 'h-10 min-w-10 px-2.5',
        },
    },
) -%}
<button
    type="button"
    class="{{ style.apply({variant: _item_variant, size: _item_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': _item_variant,
        'data-size': _item_size,
        'aria-pressed': pressed ? 'true' : 'false',
        'data-state': pressed ? 'active' : 'inactive',
    }) }}
>
    {%- 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 ? 'active' : 'inactive';
    }
}
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: "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-transparent',
            outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
        },
        size: {
            default: 'h-9 min-w-9 px-2',
            sm: 'h-8 min-w-8 px-1.5',
            lg: 'h-10 min-w-10 px-2.5',
        },
    },
) -%}
<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',
    }) }}
>
    {%- block content %}{% endblock -%}
</button>

Happy coding!

Usage

<twig:ToggleGroup>
    <twig:ToggleGroup:Item aria-label="Bold" pressed>B</twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Italic">I</twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Underline">U</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>

Sizes

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>

Vertical

Loading...
<twig:ToggleGroup class="flex-col gap-1">
    <twig:ToggleGroup:Item aria-label="Toggle bold" pressed class="rounded-lg">
        <twig:ux:icon name="lucide:bold" class="size-4" />
    </twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle italic" pressed class="rounded-lg">
        <twig:ux:icon name="lucide:italic" class="size-4" />
    </twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle underline" class="rounded-lg">
        <twig:ux:icon name="lucide:underline" class="size-4" />
    </twig:ToggleGroup:Item>
</twig:ToggleGroup>

Spacing

Loading...
<twig:ToggleGroup variant="outline" size="sm" type="single" class="gap-2 shadow-none">
    <twig:ToggleGroup:Item aria-label="Toggle top" pressed class="rounded-lg">Top</twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle bottom" class="rounded-lg">Bottom</twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle left" class="rounded-lg">Left</twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle right" class="rounded-lg">Right</twig:ToggleGroup:Item>
</twig:ToggleGroup>

Disabled

Loading...
<twig:ToggleGroup>
    <twig:ToggleGroup:Item aria-label="Toggle bold" disabled>
        <twig:ux:icon name="lucide:bold" class="size-4" />
    </twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle italic" disabled>
        <twig:ux:icon name="lucide:italic" class="size-4" />
    </twig:ToggleGroup:Item>
    <twig:ToggleGroup:Item aria-label="Toggle underline" disabled>
        <twig:ux:icon name="lucide:underline" class="size-4" />
    </twig:ToggleGroup:Item>
</twig:ToggleGroup>

Font Weight Selector

Loading...
<div class="w-auto">
    <twig:Field>
        <twig:Field:Label>Font Weight</twig:Field:Label>
        <twig:ToggleGroup variant="outline" size="lg" type="single" class="gap-2 shadow-none" data-toggle-group-label-selector-value="#font-weight-label">
            <twig:ToggleGroup:Item aria-label="Light" class="flex size-16 flex-col items-center justify-center rounded-xl">
                <span class="text-2xl leading-none font-light">Aa</span>
                <span class="text-xs text-muted-foreground">Light</span>
            </twig:ToggleGroup:Item>
            <twig:ToggleGroup:Item aria-label="Normal" pressed class="flex size-16 flex-col items-center justify-center rounded-xl">
                <span class="text-2xl leading-none font-normal">Aa</span>
                <span class="text-xs text-muted-foreground">Normal</span>
            </twig:ToggleGroup:Item>
            <twig:ToggleGroup:Item aria-label="Medium" class="flex size-16 flex-col items-center justify-center rounded-xl">
                <span class="text-2xl leading-none font-medium">Aa</span>
                <span class="text-xs text-muted-foreground">Medium</span>
            </twig:ToggleGroup:Item>
            <twig:ToggleGroup:Item aria-label="Bold" class="flex size-16 flex-col items-center justify-center rounded-xl">
                <span class="text-2xl leading-none font-bold">Aa</span>
                <span class="text-xs text-muted-foreground">Bold</span>
            </twig:ToggleGroup:Item>
        </twig:ToggleGroup>
        <twig:Field:Description>
            Use <code class="rounded-md bg-muted px-1 py-0.5 font-mono">font-<span id="font-weight-label">normal</span></code> to set the font weight.
        </twig:Field:Description>
    </twig:Field>

</div>

RTL

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

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
Block Description
content The toggle items, typically multiple ToggleGroup:Item components

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