View as Markdown

Input Group

Add addons, buttons, and helper content to inputs.

100%
Loading...
<twig:InputGroup class="max-w-xs">
    <twig:InputGroup:Input placeholder="Search..." />
    <twig:InputGroup:Addon>
        <twig:ux:icon name="lucide:search" />
    </twig:InputGroup:Addon>
    <twig:InputGroup:Addon align="inline-end">12 results</twig:InputGroup:Addon>
</twig:InputGroup>

Installation

Note

Available since UX Toolkit 3.0.

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

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Install the following front-end dependencies:

npm install shadcn tw-animate-css
# ...or with Symfony AssetMapper
php bin/console importmap:require tw-animate-css/dist/tw-animate.css shadcn/dist/tailwind.css

Copy the following file(s) into your app:

assets/controllers/input_group_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    focusInput(event) {
        if (event.target.closest('button')) {
            return;
        }

        event.currentTarget.parentElement?.querySelector('input')?.focus();
    }
}
assets/controllers/input_group_display_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['copyIcon', 'copiedIcon', 'favoriteIcon'];
    static values = { copiedDuration: { type: Number, default: 2000 } };

    #copiedTimeout = null;

    async copy(event) {
        await navigator.clipboard.writeText(event.params.text);

        this.copyIconTarget.toggleAttribute('hidden', true);
        this.copiedIconTarget.toggleAttribute('hidden', false);

        clearTimeout(this.#copiedTimeout);
        this.#copiedTimeout = setTimeout(() => {
            this.copyIconTarget.toggleAttribute('hidden', false);
            this.copiedIconTarget.toggleAttribute('hidden', true);
        }, this.copiedDurationValue);
    }

    toggleFavorite() {
        const icon = this.favoriteIconTarget;
        icon.dataset.favorite = String(icon.dataset.favorite !== 'true');
    }

    disconnect() {
        clearTimeout(this.#copiedTimeout);
    }
}
templates/components/InputGroup.html.twig
<div
    data-slot="input-group"
    role="group"
    {{ attributes.defaults({
        class: 'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5'|tailwind_classes,
        'data-controller': 'input-group',
    }) }}
>
    {##- The input group elements, typically includes input, `InputGroup:Addon`, and/or `InputGroup:Button`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/InputGroup/Addon.html.twig
{%- props
    ## 'inline-start'|'inline-end'|'block-start'|'block-end' The addon position relative to the input.
    align = 'inline-start'
-%}
{%- set style = html_cva(
    base: "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
    variants: {
        align: {
            'inline-start': 'order-first ps-2 has-[>button]:ms-[-0.3rem] has-[>kbd]:ms-[-0.15rem]',
            'inline-end': 'order-last pe-2 has-[>button]:me-[-0.3rem] has-[>kbd]:me-[-0.15rem]',
            'block-start': 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
            'block-end': 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
        },
    },
) -%}
<div
    data-slot="input-group-addon"
    data-align="{{ align }}"
    role="group"
    {{ attributes.defaults({
        class: style.apply({align: align})|tailwind_classes,
        'data-action': 'click->input-group#focusInput'|html_attr_type('sst'),
    }) }}
>
    {##- The addon content, typically an icon or text. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/InputGroup/Button.html.twig
{%- props
    ## 'button'|'submit' The button type.
    type = 'button',
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'ghost',
    ## 'xs'|'sm'|'icon-xs'|'icon-sm' The button size.
    size = 'xs'
-%}
{%- set style = html_cva(
    base: 'flex items-center gap-2 text-sm shadow-none',
    variants: {
        size: {
            xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
            sm: '',
            'icon-xs': 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
            'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
        },
    },
) %}
<twig:Button
    type="{{ type }}"
    variant="{{ variant }}"
    {{ ...attributes.defaults({class: style.apply({size: size})|tailwind_classes}) }}
>
    {##- The button label and/or icon. -#}
    {{- block(outerBlocks.content) -}}
</twig:Button>
templates/components/InputGroup/Input.html.twig
<twig:Input
    data-slot="input-group-control"
    {{ ...attributes.defaults({class: 'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent'|tailwind_classes}) }}
/>
templates/components/InputGroup/Text.html.twig
<span
    data-slot="input-group-text"
    {{ attributes.defaults({
        class: 'text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*=size-])]:size-4'|tailwind_classes,
    }) }}
>
    {##- The text content displayed in the input group. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/InputGroup/Textarea.html.twig
<twig:Textarea
    data-slot="input-group-control"
    {{ ...attributes.defaults({class: 'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent'|tailwind_classes}) }}
>
    {##- The initial textarea value. -#}
    {{- block(outerBlocks.content) -}}
</twig:Textarea>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    as = 'button'
-%}
{%- set style = html_cva(
    base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground hover:bg-primary/80',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
            destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
            sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            icon: 'size-8',
            'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
            'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
            'icon-lg': 'size-9',
        },
    },
) -%}
<{{ as }}
    data-slot="{{ attributes.render('data-slot')|default('button') }}"
    data-variant="{{ variant }}"
    data-size="{{ size }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Input.html.twig
<input
    data-slot="{{ attributes.render('data-slot')|default('input') }}"
    {{ attributes.defaults({
        class: 'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40'|tailwind_classes,
    }) }}
>
templates/components/Textarea.html.twig
<textarea
    data-slot="{{ attributes.render('data-slot')|default('textarea') }}"
    {{ attributes.defaults({
        class: 'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40'|tailwind_classes,
    }) }}
>
    {##- The initial textarea value. -#}
    {%- block content %}{% endblock -%}
</textarea>

Usage

<twig:InputGroup>
    <twig:InputGroup:Input placeholder="Search..." />
    <twig:InputGroup:Addon>
        <twig:ux:icon name="lucide:search" />
    </twig:InputGroup:Addon>
</twig:InputGroup>

Examples

Inline Start

Use align="inline-start" to position the addon at the start of the input. This is the default.

100%
Loading...
<twig:Field class="max-w-sm">
    <twig:Field:Label for="inline-start-input">Input</twig:Field:Label>
    <twig:InputGroup>
        <twig:InputGroup:Input id="inline-start-input" placeholder="Search..." />
        <twig:InputGroup:Addon align="inline-start">
            <twig:ux:icon name="lucide:search" class="text-muted-foreground" />
        </twig:InputGroup:Addon>
    </twig:InputGroup>
    <twig:Field:Description>Icon positioned at the start.</twig:Field:Description>
</twig:Field>

Inline End

Use align="inline-end" to position the addon at the end of the input.

100%
Loading...
<twig:Field class="max-w-sm">
    <twig:Field:Label for="inline-end-input">Input</twig:Field:Label>
    <twig:InputGroup>
        <twig:InputGroup:Input id="inline-end-input" type="password" placeholder="Enter password" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:ux:icon name="lucide:eye-off" />
        </twig:InputGroup:Addon>
    </twig:InputGroup>
    <twig:Field:Description>Icon positioned at the end.</twig:Field:Description>
</twig:Field>

Block Start

Use align="block-start" to position the addon above the input.

100%
Loading...
<twig:Field:Group class="max-w-sm">
    <twig:Field>
        <twig:Field:Label for="block-start-input">Input</twig:Field:Label>
        <twig:InputGroup class="h-auto">
            <twig:InputGroup:Input id="block-start-input" placeholder="Enter your name" />
            <twig:InputGroup:Addon align="block-start">
                <twig:InputGroup:Text>Full Name</twig:InputGroup:Text>
            </twig:InputGroup:Addon>
        </twig:InputGroup>
        <twig:Field:Description>Header positioned above the input.</twig:Field:Description>
    </twig:Field>
    <twig:Field>
        <twig:Field:Label for="block-start-textarea">Textarea</twig:Field:Label>
        <twig:InputGroup>
            <twig:InputGroup:Textarea
                id="block-start-textarea"
                placeholder="console.log('Hello, world!');"
                class="font-mono text-sm"
            />
            <twig:InputGroup:Addon align="block-start">
                <twig:ux:icon name="lucide:file-code" class="text-muted-foreground" />
                <twig:InputGroup:Text class="font-mono">script.js</twig:InputGroup:Text>
                <twig:InputGroup:Button size="icon-xs" class="ms-auto">
                    <twig:ux:icon name="lucide:copy" />
                    <span class="sr-only">Copy</span>
                </twig:InputGroup:Button>
            </twig:InputGroup:Addon>
        </twig:InputGroup>
        <twig:Field:Description>Header positioned above the textarea.</twig:Field:Description>
    </twig:Field>
</twig:Field:Group>

Block End

Use align="block-end" to position the addon below the input.

100%
Loading...
<twig:Field:Group class="max-w-sm">
    <twig:Field>
        <twig:Field:Label for="block-end-input">Input</twig:Field:Label>
        <twig:InputGroup class="h-auto">
            <twig:InputGroup:Input id="block-end-input" placeholder="Enter amount" />
            <twig:InputGroup:Addon align="block-end">
                <twig:InputGroup:Text>USD</twig:InputGroup:Text>
            </twig:InputGroup:Addon>
        </twig:InputGroup>
        <twig:Field:Description>Footer positioned below the input.</twig:Field:Description>
    </twig:Field>
    <twig:Field>
        <twig:Field:Label for="block-end-textarea">Textarea</twig:Field:Label>
        <twig:InputGroup>
            <twig:InputGroup:Textarea id="block-end-textarea" placeholder="Write a comment..." />
            <twig:InputGroup:Addon align="block-end">
                <twig:InputGroup:Text>0/280</twig:InputGroup:Text>
                <twig:InputGroup:Button variant="default" size="sm" class="ms-auto">Post</twig:InputGroup:Button>
            </twig:InputGroup:Addon>
        </twig:InputGroup>
        <twig:Field:Description>Footer positioned below the textarea.</twig:Field:Description>
    </twig:Field>
</twig:Field:Group>

Icon

100%
Loading...
<div class="grid w-full max-w-sm gap-6">
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Search..." />
        <twig:InputGroup:Addon>
            <twig:ux:icon name="lucide:search" />
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input type="email" placeholder="Enter your email" />
        <twig:InputGroup:Addon>
            <twig:ux:icon name="lucide:mail" />
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Card number" />
        <twig:InputGroup:Addon>
            <twig:ux:icon name="lucide:credit-card" />
        </twig:InputGroup:Addon>
        <twig:InputGroup:Addon align="inline-end">
            <twig:ux:icon name="lucide:check" />
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Card number" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:ux:icon name="lucide:star" />
            <twig:ux:icon name="lucide:info" />
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Text

100%
Loading...
<div class="grid w-full max-w-sm gap-6">
    <twig:InputGroup>
        <twig:InputGroup:Addon>
            <twig:InputGroup:Text>$</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
        <twig:InputGroup:Input placeholder="0.00" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Text>USD</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Addon>
            <twig:InputGroup:Text>https://</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
        <twig:InputGroup:Input placeholder="example.com" class="ps-0.5!" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Text>.com</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Enter your username" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Text>@company.com</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Textarea placeholder="Enter your message" />
        <twig:InputGroup:Addon align="block-end">
            <twig:InputGroup:Text class="text-xs text-muted-foreground">120 characters left</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Button

100%
Loading...
<div class="grid w-full max-w-sm gap-6" data-controller="input-group-display">
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="https://x.com/symfony" readonly />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Button
                aria-label="Copy"
                title="Copy"
                size="icon-xs"
                data-action="input-group-display#copy"
                data-input-group-display-text-param="https://x.com/symfony"
            >
                <twig:ux:icon name="tabler:copy" data-input-group-display-target="copyIcon" />
                <twig:ux:icon name="tabler:check" data-input-group-display-target="copiedIcon" hidden />
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup class="[--radius:9999px]">
        <twig:Popover class="order-first">
            <twig:Popover:Trigger>
                <twig:InputGroup:Addon>
                    <twig:InputGroup:Button variant="secondary" size="icon-xs" aria-label="Connection info" {{ ...popover_trigger_attrs }}>
                        <twig:ux:icon name="tabler:info-circle" />
                    </twig:InputGroup:Button>
                </twig:InputGroup:Addon>
            </twig:Popover:Trigger>
            <twig:Popover:Content align="start" class="flex flex-col gap-1 rounded-xl text-sm [--radius:0.625rem]">
                <p class="font-medium">Your connection is not secure.</p>
                <p>You should not enter any sensitive information on this site.</p>
            </twig:Popover:Content>
        </twig:Popover>
        <twig:InputGroup:Addon class="ps-1.5 text-muted-foreground">
            https://
        </twig:InputGroup:Addon>
        <twig:InputGroup:Input id="input-secure-19" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Button size="icon-xs" aria-label="Favorite" data-action="input-group-display#toggleFavorite">
                <twig:ux:icon
                    name="tabler:star"
                    data-favorite="false"
                    data-input-group-display-target="favoriteIcon"
                    class="data-[favorite=true]:*:fill-blue-600 data-[favorite=true]:*:stroke-blue-600"
                />
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Type to search..." />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Button variant="secondary">Search</twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Kbd

100%
Loading...
<twig:InputGroup class="max-w-sm">
    <twig:InputGroup:Input placeholder="Search..." />
    <twig:InputGroup:Addon>
        <twig:ux:icon name="lucide:search" class="text-muted-foreground" />
    </twig:InputGroup:Addon>
    <twig:InputGroup:Addon align="inline-end">
        <twig:Kbd>⌘K</twig:Kbd>
    </twig:InputGroup:Addon>
</twig:InputGroup>

Dropdown

100%
Loading...
<div class="grid w-full max-w-sm content-start gap-4" style="min-height: 220px">
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Enter file name" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:DropdownMenu id="input-group-file" align="end">
                <twig:DropdownMenu:Trigger>
                    <twig:InputGroup:Button variant="ghost" aria-label="More" size="icon-xs" {{ ...dropdown_menu_trigger_attrs }}>
                        <twig:ux:icon name="lucide:ellipsis" />
                    </twig:InputGroup:Button>
                </twig:DropdownMenu:Trigger>
                <twig:DropdownMenu:Content>
                    <twig:DropdownMenu:Group>
                        <twig:DropdownMenu:Item>Settings</twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Item>Copy path</twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Item>Open location</twig:DropdownMenu:Item>
                    </twig:DropdownMenu:Group>
                </twig:DropdownMenu:Content>
            </twig:DropdownMenu>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup class="[--radius:1rem]">
        <twig:InputGroup:Input placeholder="Enter search query" />
        <twig:InputGroup:Addon align="inline-end">
            <twig:DropdownMenu id="input-group-search" align="end">
                <twig:DropdownMenu:Trigger>
                    <twig:InputGroup:Button variant="ghost" class="pe-1.5! text-xs" {{ ...dropdown_menu_trigger_attrs }}>
                        Search In... <twig:ux:icon name="lucide:chevron-down" class="size-3" />
                    </twig:InputGroup:Button>
                </twig:DropdownMenu:Trigger>
                <twig:DropdownMenu:Content class="[--radius:0.95rem]">
                    <twig:DropdownMenu:Group>
                        <twig:DropdownMenu:Item>Documentation</twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Item>Blog Posts</twig:DropdownMenu:Item>
                        <twig:DropdownMenu:Item>Changelog</twig:DropdownMenu:Item>
                    </twig:DropdownMenu:Group>
                </twig:DropdownMenu:Content>
            </twig:DropdownMenu>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Spinner

100%
Loading...
<div class="grid w-full max-w-sm gap-4">
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Searching..." />
        <twig:InputGroup:Addon align="inline-end">
            <twig:Spinner />
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Processing..." />
        <twig:InputGroup:Addon>
            <twig:Spinner />
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Saving changes..." />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Text>Saving...</twig:InputGroup:Text>
            <twig:Spinner />
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="Refreshing data..." />
        <twig:InputGroup:Addon>
            <twig:ux:icon name="lucide:loader" class="animate-spin" />
        </twig:InputGroup:Addon>
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Text class="text-muted-foreground">Please wait...</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Textarea

100%
Loading...
<div class="grid w-full max-w-md gap-4">
    <twig:InputGroup>
        <twig:InputGroup:Textarea
            id="textarea-code-32"
            placeholder="console.log('Hello, world!');"
            class="min-h-[200px]"
        />
        <twig:InputGroup:Addon align="block-end" class="border-t">
            <twig:InputGroup:Text>Line 1, Column 1</twig:InputGroup:Text>
            <twig:InputGroup:Button size="sm" class="ms-auto" variant="default">
                Run <twig:ux:icon name="tabler:corner-down-left" />
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
        <twig:InputGroup:Addon align="block-start" class="border-b">
            <twig:InputGroup:Text class="font-mono font-medium">
                <twig:ux:icon name="tabler:brand-javascript" />
                script.js
            </twig:InputGroup:Text>
            <twig:InputGroup:Button class="ms-auto" size="icon-xs" aria-label="Refresh">
                <twig:ux:icon name="tabler:refresh" />
            </twig:InputGroup:Button>
            <twig:InputGroup:Button variant="ghost" size="icon-xs" aria-label="Copy">
                <twig:ux:icon name="tabler:copy" />
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Custom Input

Add the data-slot="input-group-control" attribute to your custom input for automatic focus state handling.

Here's an example of a custom textarea that grows with its content.

100%
Loading...
<div class="grid w-full max-w-sm gap-6">
    <twig:InputGroup>
        <textarea
            data-slot="input-group-control"
            class="flex field-sizing-content min-h-16 w-full resize-none rounded-md bg-transparent px-3 py-2.5 text-base transition-[color,box-shadow] outline-none md:text-sm"
            placeholder="Autoresize textarea..."
        ></textarea>
        <twig:InputGroup:Addon align="block-end">
            <twig:InputGroup:Button class="ms-auto" size="sm" variant="default">Submit</twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<div class="w-full flex flex-col items-center gap-16">
    <div dir="rtl" class="grid w-full max-w-sm gap-6">
        <twig:InputGroup class="max-w-xs">
            <twig:InputGroup:Input placeholder="بحث..." />
            <twig:InputGroup:Addon>
                <twig:ux:icon name="lucide:search" />
            </twig:InputGroup:Addon>
            <twig:InputGroup:Addon align="inline-end">١٢ نتيجة</twig:InputGroup:Addon>
        </twig:InputGroup>

        <twig:InputGroup>
            <twig:InputGroup:Input placeholder="جاري البحث..." />
            <twig:InputGroup:Addon align="inline-end">
                <twig:Spinner />
            </twig:InputGroup:Addon>
        </twig:InputGroup>

        <twig:InputGroup>
            <twig:InputGroup:Input placeholder="جاري حفظ التغييرات..." />
            <twig:InputGroup:Addon align="inline-end">
                <twig:InputGroup:Text>جاري الحفظ...</twig:InputGroup:Text>
                <twig:Spinner />
            </twig:InputGroup:Addon>
        </twig:InputGroup>

        <twig:Field:Group class="max-w-sm">
            <twig:Field>
                <twig:Field:Label for="rtl-ar-textarea">منطقة النص</twig:Field:Label>
                <twig:InputGroup>
                    <twig:InputGroup:Textarea id="rtl-ar-textarea" placeholder="اكتب تعليقًا..." />
                    <twig:InputGroup:Addon align="block-end">
                        <twig:InputGroup:Text>٠/٢٨٠</twig:InputGroup:Text>
                        <twig:InputGroup:Button variant="default" size="sm" class="ms-auto">نشر</twig:InputGroup:Button>
                    </twig:InputGroup:Addon>
                </twig:InputGroup>
                <twig:Field:Description>تذييل موضع أسفل منطقة النص.</twig:Field:Description>
            </twig:Field>
        </twig:Field:Group>
    </div>

    <div dir="rtl" class="grid w-full max-w-sm gap-6">
        <twig:InputGroup class="max-w-xs">
            <twig:InputGroup:Input placeholder="חפש..." />
            <twig:InputGroup:Addon>
                <twig:ux:icon name="lucide:search" />
            </twig:InputGroup:Addon>
            <twig:InputGroup:Addon align="inline-end">12 תוצאות</twig:InputGroup:Addon>
        </twig:InputGroup>

        <twig:InputGroup>
            <twig:InputGroup:Input placeholder="מחפש..." />
            <twig:InputGroup:Addon align="inline-end">
                <twig:Spinner />
            </twig:InputGroup:Addon>
        </twig:InputGroup>

        <twig:InputGroup>
            <twig:InputGroup:Input placeholder="שומר שינויים..." />
            <twig:InputGroup:Addon align="inline-end">
                <twig:InputGroup:Text>שומר...</twig:InputGroup:Text>
                <twig:Spinner />
            </twig:InputGroup:Addon>
        </twig:InputGroup>

        <twig:Field:Group class="max-w-sm">
            <twig:Field>
                <twig:Field:Label for="rtl-he-textarea">אזור טקסט</twig:Field:Label>
                <twig:InputGroup>
                    <twig:InputGroup:Textarea id="rtl-he-textarea" placeholder="כתוב תגובה..." />
                    <twig:InputGroup:Addon align="block-end">
                        <twig:InputGroup:Text>0/280</twig:InputGroup:Text>
                        <twig:InputGroup:Button variant="default" size="sm" class="ms-auto">פרסם</twig:InputGroup:Button>
                    </twig:InputGroup:Addon>
                </twig:InputGroup>
                <twig:Field:Description>כותרת תחתונה ממוקמת מתחת לאזור הטקסט.</twig:Field:Description>
            </twig:Field>
        </twig:Field:Group>
    </div>
</div>

Accessibility

  • InputGroup renders role="group", so the control and its addons are announced as one unit. Give it an aria-label when the group needs a name of its own.
  • An addon is not a label. Keep a real Label bound to the control with for and id, even when the addon already shows a unit or a prefix.
  • Place each InputGroup:Addon after the control in the markup. Position it with align. The Tab order follows the markup, so the control is reached before the buttons of its addons.
  • An icon-only InputGroup:Button needs an aria-label, since <twig:ux:icon> renders aria-hidden="true".
  • Purely decorative text in an InputGroup:Addon, such as a currency symbol already stated in the label, is still announced. Move it out or hide it with aria-hidden="true" when it only repeats what the label says.
  • Set aria-invalid="true" on the control rather than on the group, so the invalid state lands on the element the user is editing.

API Reference

<twig:InputGroup>

Block Description
content The input group elements, typically includes input, InputGroup:Addon, and/or InputGroup:Button.

<twig:InputGroup:Addon>

Prop Type Default
align 
'inline-start'|'inline-end'|'block-start'|'block-end' 'inline-start'
Block Description
content The addon content, typically an icon or text.

<twig:InputGroup:Button>

Prop Type Default
type 
'button'|'submit' 'button'
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'ghost'
size 
'xs'|'sm'|'icon-xs'|'icon-sm' 'xs'
Block Description
content The button label and/or icon.

<twig:InputGroup:Text>

Block Description
content The text content displayed in the input group.

<twig:InputGroup:Textarea>

Block Description
content The initial textarea value.