Input Group

Wraps inputs with optional leading or trailing elements like icons or keyboard hints.

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" class="size-4" />
        </twig:InputGroup:Addon>
        <twig:InputGroup:Addon align="inline-end">12 results</twig:InputGroup:Addon>
    </twig:InputGroup>
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="example.com" class="!pl-1" />
        <twig:InputGroup:Addon>
            <twig:InputGroup:Text>https://</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
        <twig:InputGroup:Addon align="inline-end">
            <twig:ux:icon name="tabler:info-circle" class="size-4" title="This is content in (not) a tooltip. (yet!)"/>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
    <twig:InputGroup>
        <twig:InputGroup:Textarea placeholder="Ask, Search or Chat..." />
        <twig:InputGroup:Addon align="block-end">
            <twig:InputGroup:Button
                variant="outline"
                class="rounded-full"
                size="icon-xs"
            >
                <twig:ux:icon name="lucide:plus"  />
            </twig:InputGroup:Button>

            <twig:InputGroup:Text class="ml-auto">52% used</twig:InputGroup:Text>
            <twig:Separator orientation="vertical" class="!h-4" />
            <twig:InputGroup:Button
                variant="default"
                class="rounded-full"
                size="icon-xs"
                disabled
            >
                <twig:ux:icon name="tabler:circle-arrow-up-filled" class="size-5" />
                <span class="sr-only">Send</span>
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="@shadcn" />
        <twig:InputGroup:Addon align="inline-end">
            <div class="bg-primary text-primary-foreground flex size-4 items-center justify-center rounded-full">
                <twig:ux:icon name="tabler:check" />
            </div>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Installation

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

That's it!

Install the following Composer dependencies:

composer require symfony/ux-icons tales-from-a-dev/twig-tailwind-extra:^1.0.0

Copy the following file(s) into your Symfony app:

{# @block content The default block #}
<div
    data-slot="input-group"
    class="{{ [
        'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
        'h-9 min-w-0 has-[>textarea]:h-auto',

        'has-[>[data-align=inline-start]]:[&>input]:pl-2',
        'has-[>[data-align=inline-end]]:[&>input]:pr-2',
        'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
        'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',

        'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',

        'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
        attributes.render('class'),
    ]|join(' ')|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
{# @prop align 'inline-start'|'inline-end'|'block-start'|'block-end' The addon alignment, default to `inline-start` #}
{# @block content The default block #}
{%- props align = 'inline-start' -%}
{%- set style = html_cva(
    base: "text-muted-foreground flex h-auto items-center justify-center gap-2 text-sm font-medium select-none [&_svg:not([class*='size-'])]:size-4 group-data-[disabled=true]/input-group:opacity-50",
    variants: {
        align: {
            'inline-start': 'order-first pl-3',
            'inline-end': 'order-last pr-3',
            'block-start': 'order-first w-full justify-start px-3 pt-3 [&.border-b]:pb-3',
            'block-end': 'order-last w-full justify-start px-3 pb-3 [&.border-t]:pt-3',
        },
    },
) -%}
<div
    data-slot="input-group-addon"
    data-align="{{ align }}"
    role="group"
    class="{{ style.apply({align: align}, attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
{# @prop type 'button'|'submit' The type, default to `button` #}
{# @prop variant 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The variant, default to `default` #}
{# @prop size 'default'|'sm'|'lg'|'icon'|'icon-sm'|'icon-lg' The size, default to `default` #}
{# @block content The default block #}
{%- props type = 'button', variant = 'ghost', size = 'xs' -%}
{%- set style = html_cva(
    base: 'text-sm shadow-none flex gap-2 items-center',
    variants: {
        size: {
            xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
            sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
            'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
            'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
        },
    },
) %}
<twig:Button
    type="{{ type }}"
    variant="{{ variant }}"
    size="{{ size }}"
    class="{{ style.apply({size: size}, attributes.render('class'))|tailwind_merge }}"
    {{ ...attributes }}
>
    {{- block(outerBlocks.content) -}}
</twig:Button>
<input
    data-slot="input-group-control"
    class="{{ ('flex-1 h-full w-full rounded-none border-0 bg-transparent px-3 py-2 text-sm shadow-none file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
{# @block content The default block #}
<span
    data-slot="input-group-text"
    class="{{ ('text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*=size-])]:size-4 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</span>
{# @block content The default block #}
<textarea
    data-slot="input-group-control"
    class="{{ ('flex-1 min-h-[80px] w-full resize-none rounded-none border-0 bg-transparent px-3 py-3 text-base shadow-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</textarea>
{%- props label = 'Loading' -%}

{{ ux_icon('lucide:loader-2', {
    'data-slot': 'spinner',
    'aria-label': label,
    role: 'status',
    class: 'size-4 animate-spin ' ~ attributes.render('class')|tailwind_merge,
    ...attributes.without('class'),
}) }}

Happy coding!

Usage

<twig:InputGroup>
    <twig:InputGroup:Input placeholder="Search..." />
    <twig:InputGroup:Addon>
        <twig:ux:icon name="lucide:search" />
    </twig:InputGroup:Addon>
    <twig:InputGroup:Addon align="inline-end">
        <twig:InputGroup:Button>Search</twig:InputGroup:Button>
    </twig:InputGroup:Addon>
</twig:InputGroup>

Examples

Icon

Loading...
<div class="grid w-full max-w-sm gap-6" xmlns:twig="http://www.w3.org/1999/html">
    <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 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

Display additional text information alongside inputs.

Loading...
<div class="grid w-full max-w-sm gap-6" xmlns:twig="http://www.w3.org/1999/html">
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="0.00" />
        <twig:InputGroup:Addon>
            <twig:InputGroup:Text>$</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Text>USD</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="example.com" />
        <twig:InputGroup:Addon>
            <twig:InputGroup:Text>https://</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-muted-foreground text-xs">120 characters left</twig:InputGroup:Text>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Button

Loading...
<div class="grid w-full max-w-sm gap-6" xmlns:twig="http://www.w3.org/1999/html">
    <twig:InputGroup>
        <twig:InputGroup:Input placeholder="https://twitter.com/symfony" readonly />
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Button title="Copy to clipboard">
                <twig:ux:Icon name="lucide:copy" />
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup class="[--radius:9999px]">
        <twig:InputGroup:Input placeholder="https://" />
        <twig:InputGroup:Addon>
            <twig:InputGroup:Button variant="secondary" size="icon-xs">
                <twig:ux:Icon name="lucide:info" />
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
        <twig:InputGroup:Addon align="inline-end">
            <twig:InputGroup:Button title="Copy to clipboard">
                <twig:ux:Icon name="lucide:star" />
            </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>

Textarea

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="ml-auto" variant="default">
                Run <twig:ux:icon name="lucide: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="ri:javascript-fill" />
                script.js
            </twig:InputGroup:Text>
            <twig:InputGroup:Button class="ml-auto" size="icon-xs">
                <twig:ux:icon name="lucide:refresh-ccw" />            </twig:InputGroup:Button>
            <InputGroup:Button variant="ghost" size="icon-xs">
                <twig:ux:icon name="lucide:copy" />
            </InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Spinner

Loading...
<div class="max-w-md flex flex-col gap-4">
  <twig:InputGroup data-disabled="true">
    <twig:InputGroup:Input placeholder="Searching..." disabled />
    <twig:InputGroup:Addon align="inline-end">
      <twig:Spinner />
    </twig:InputGroup:Addon>
  </twig:InputGroup>

  <twig:InputGroup data-disabled="true">
    <twig:InputGroup:Input placeholder="Processing..." disabled />
    <twig:InputGroup:Addon>
      <twig:Spinner />
    </twig:InputGroup:Addon>
  </twig:InputGroup>

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

  <twig:InputGroup data-disabled="true">
    <twig:InputGroup:Input placeholder="Refreshing data..." disabled />
    <twig:InputGroup:Addon>
      {{ ux_icon('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>

Label

Loading...
<div class="grid w-full max-w-sm gap-6" xmlns:twig="http://www.w3.org/1999/html">
    <twig:InputGroup>
        <twig:InputGroup:Input id="username" placeholder="symfony" />
        <twig:InputGroup:Addon>
            <twig:Label for="username">@</twig:Label>
        </twig:InputGroup:Addon>
    </twig:InputGroup>

    <twig:InputGroup>
        <twig:InputGroup:Input id="email" placeholder="symfony" />
        <twig:InputGroup:Addon align="block-start">
            <twig:Label for="email" class="text-foreground">Email</twig:Label>
            <twig:InputGroup:Button variant="ghost" aria-label="Help" class="ml-auto rounded-full" size="xs">
                <twig:ux:Icon name="lucide:help-circle" />
            </twig:InputGroup:Button>
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>

Button Group

Loading...
<div class="grid w-full max-w-sm gap-6" xmlns:twig="http://www.w3.org/1999/html">

    <twig:ButtonGroup>
        <twig:ButtonGroup:Text>
            <Label for="url">https://</Label>
        </twig:ButtonGroup:Text>
        <twig:InputGroup>
            <twig:InputGroup:Input id="url" placeholder="example.com" />
            <twig:InputGroup:Addon align="inline-end">
                <twig:ux:icon name="lucide:link-2" />
            </twig:InputGroup:Addon>
        </twig:InputGroup>
        <twig:ButtonGroup:Text>
            .com
        </twig:ButtonGroup:Text>
    </twig:ButtonGroup>

</div>

API Reference

InputGroup

Block Description
content The default block

InputGroup:Addon

Prop Type Description
align 'inline-start'|'inline-end'|'block-start'|'block-end' The addon alignment, default to inline-start
Block Description
content The default block

InputGroup:Button

Prop Type Description
type 'button'|'submit' The type, default to button
variant 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The variant, default to default
size 'default'|'sm'|'lg'|'icon'|'icon-sm'|'icon-lg' The size, default to default
Block Description
content The default block

InputGroup:Text

Block Description
content The default block

InputGroup:Textarea

Block Description
content The default block