Field

Layout helpers for form fields with labels, descriptions, errors, grouping, and separators.

Loading...
<div class="w-full max-w-md">
    <twig:Field:Set>
        <twig:Field:Group>
            <twig:Field>
                <twig:Field:Label for="username">Username</twig:Field:Label>
                <twig:Input id="username" type="text" placeholder="Max Leiter" />
                <twig:Field:Description>
                    Choose a unique username for your account.
                </twig:Field:Description>
            </twig:Field>
            <twig:Field>
                <twig:Field:Label for="password">Password</twig:Field:Label>
                <twig:Field:Description>
                    Must be at least 8 characters long.
                </twig:Field:Description>
                <twig:Input id="password" type="password" placeholder="********" />
            </twig:Field>
        </twig:Field:Group>
    </twig:Field:Set>
</div>

Installation

Ensure the Symfony UX Toolkit is installed in your Symfony app:

composer require --dev symfony/ux-toolkit

Then, install the recipe and its dependencies by running:

bin/console ux:install Field --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:

{# @prop orientation 'vertical'|'horizontal'|'responsive' The orientation of the field, default to `vertical` #}
{# @block content The default block #}
{%- props orientation = 'vertical' -%}
{%- set style = html_cva(
    base: 'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
    variants: {
        orientation: {
            vertical: 'flex-col [&>*]:w-full [&>.sr-only]:w-auto',
            horizontal: 'flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
            responsive: 'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
        },
    },
) -%}

<div
    role="group"
    data-slot="field"
    data-orientation="{{ orientation }}"
    class="{{ style.apply({orientation: orientation}, attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
{# @block content The default block #}
<div
    data-slot="field-content"
    class="{{ 'group/field-content flex flex-1 flex-col gap-1.5 leading-snug ' ~ attributes.render('class')|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
{# @block content The default block #}
<p
    data-slot="field-description"
    class="{{ 'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5 [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4 ' ~ attributes.render('class')|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</p>
{# @prop errors array A list of errors (string or objects with a `message` field), defaults to `[]` #}
{# @block content The default block #}
{%- props errors = [] -%}
{%- set slot_content -%}{%- block content %}{% endblock -%}{%- endset -%}
{%- set slot_content = slot_content|trim -%}

{%- if slot_content == '' -%}
    {%- set messages = [] -%}
    {%- for error in errors|default([]) -%}
        {%- set message = error.message ?? error -%}
        {%- if message is not same as(false) and message is not null and message != '' and not (message in messages) -%}
            {%- set messages = messages|merge([message]) -%}
        {%- endif -%}
    {%- endfor -%}
{%- endif -%}

{%- if slot_content != '' or (messages ?? [])|length > 0 -%}
    <div
        role="alert"
        data-slot="field-error"
        class="{{ 'text-destructive text-sm font-normal ' ~ attributes.render('class')|tailwind_merge }}"
        {{ attributes }}
    >
        {%- if slot_content != '' -%}
            {{ slot_content }}
        {%- elseif (messages ?? [])|length == 1 -%}
            {{ messages[0] }}
        {%- else -%}
            <ul class="ml-4 flex list-disc flex-col gap-1">
                {%- for message in messages|default([]) -%}
                    <li>{{ message }}</li>
                {%- endfor -%}
            </ul>
        {%- endif -%}
    </div>
{%- endif -%}
{# @block content The default block #}
<div
    data-slot="field-group"
    class="{{ 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4 ' ~ attributes.render('class')|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
{# @block content The default block #}
<twig:Label
    data-slot="field-label"
    class="{{ 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10 ' ~ attributes.render('class')|tailwind_merge }}"
    {{ ...attributes }}
>
    {{- block(outerBlocks.content) -}}
</twig:Label>
{# @prop variant 'legend'|'label' The variant, default to `legend` #}
{# @block content The default block #}
{%- props variant = 'legend' -%}
{%- set style = html_cva(
    base: 'mb-3 font-medium',
    variants: {
        variant: {
            legend: 'text-base',
            label: 'text-sm',
        },
    },
) %}
<legend
    data-slot="field-legend"
    data-variant="{{ variant }}"
    class="{{ style.apply({variant: variant}, attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</legend>
{# @block content The default block #}
{%- set content -%}{%- block content %}{% endblock -%}{%- endset -%}
{%- set has_content = content|trim != '' -%}
<div
    data-slot="field-separator"
    data-content="{{ has_content ? 'true' : 'false' }}"
    class="{{ 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 ' ~ attributes.render('class')|tailwind_merge }}"
    {{ attributes }}
>
    <twig:Separator class="absolute inset-0 top-1/2" />
    {%- if has_content -%}
        <span
            class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
            data-slot="field-separator-content"
        >
            {{ content }}
        </span>
    {%- endif -%}
</div>
{# @block content The default block #}
<fieldset
    data-slot="field-set"
    class="{{ 'flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 ' ~ attributes.render('class')|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</fieldset>
{# @block content The default block #}
<div
    data-slot="field-label"
    class="{{ 'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50 ' ~ attributes.render('class')|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
{# @prop orientation 'horizontal'|'vertical' The orientation of the separator, default to `horizontal` #}
{# @prop decorative boolean Whether the separator is decorative or not, default to `true` #}
{%- props orientation = 'horizontal', decorative = true -%}
{%- set style = html_cva(
    base: 'shrink-0 bg-border',
    variants: {
        orientation: {
            horizontal: 'h-[1px] w-full',
            vertical: 'h-full w-[1px]',
        },
    },
) -%}
<div
    class="{{ style.apply({orientation: orientation, decorative: decorative}, attributes.render('class'))|tailwind_merge }}"
    {{ attributes.defaults({
        role: decorative ? 'none' : 'separator',
        'aria-orientation': decorative ? false : orientation,
    }) }}
></div>
{# @block content The default block #}
<label
    class="{{ ('flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</label>

Happy coding!

Usage

<div class="w-full max-w-md">
    <twig:Field:Set>
        <twig:Field:Group>
            <twig:Field>
                <twig:Field:Label for="username">Username</twig:Field:Label>
                <twig:Input id="username" type="text" placeholder="Max Leiter" />
                <twig:Field:Description>
                    Choose a unique username for your account.
                </twig:Field:Description>
            </twig:Field>
            <twig:Field>
                <twig:Field:Label for="password">Password</twig:Field:Label>
                <twig:Field:Description>
                    Must be at least 8 characters long.
                </twig:Field:Description>
                <twig:Input id="password" type="password" placeholder="********" />
            </twig:Field>
        </twig:Field:Group>
    </twig:Field:Set>
</div>

Examples

Input

Loading...
<div class="w-full max-w-md">
    <twig:Field:Set>
        <twig:Field:Group>
            <twig:Field>
                <twig:Field:Label for="username">Username</twig:Field:Label>
                <twig:Input id="username" type="text" placeholder="Max Leiter" />
                <twig:Field:Description>
                    Choose a unique username for your account.
                </twig:Field:Description>
            </twig:Field>
            <twig:Field>
                <twig:Field:Label for="password">Password</twig:Field:Label>
                <twig:Field:Description>
                    Must be at least 8 characters long.
                </twig:Field:Description>
                <twig:Input id="password" type="password" placeholder="********" />
            </twig:Field>
        </twig:Field:Group>
    </twig:Field:Set>
</div>

Textarea

Loading...
<div class="w-full max-w-md">
    <twig:Field:Set>
        <twig:Field:Group>
            <twig:Field>
                <twig:Field:Label for="feedback">Feedback</twig:Field:Label>
                <twig:Textarea
                    id="feedback"
                    placeholder="Your feedback helps us improve..."
                    rows="4"
                />
                <twig:Field:Description>
                    Share your thoughts about our service.
                </twig:Field:Description>
            </twig:Field>
        </twig:Field:Group>
    </twig:Field:Set>
</div>

Select

Loading...
<div class="w-full max-w-md">
    <twig:Field>
        <twig:Field:Label for="department">Department</twig:Field:Label>
        <twig:Select id="department">
            <option value="engineering">Engineering</option>
            <option value="design">Design</option>
            <option value="marketing">Marketing</option>
            <option value="sales">Sales</option>
            <option value="support">Customer Support</option>
            <option value="hr">Human Resources</option>
            <option value="finance">Finance</option>
            <option value="operations">Operations</option>
        </twig:Select>
        <twig:Field:Description>
            Select your department or area of work.
        </twig:Field:Description>
    </twig:Field>
</div>

Field set

Loading...
<div class="w-full max-w-md space-y-6">
    <twig:Field:Set>
        <twig:Field:Legend>Address information</twig:Field:Legend>
        <twig:Field:Description>
            We need your address to deliver your order.
        </twig:Field:Description>
        <twig:Field:Group>
            <twig:Field>
                <twig:Field:Label for="street">Street address</twig:Field:Label>
                <twig:Input id="street" type="text" placeholder="123 Main St" />
            </twig:Field>
            <div class="grid grid-cols-2 gap-4">
                <twig:Field>
                    <twig:Field:Label for="city">City</twig:Field:Label>
                    <twig:Input id="city" type="text" placeholder="New York" />
                </twig:Field>
                <twig:Field>
                    <twig:Field:Label for="zip">Postal code</twig:Field:Label>
                    <twig:Input id="zip" type="text" placeholder="90502" />
                </twig:Field>
            </div>
        </twig:Field:Group>
    </twig:Field:Set>
</div>

Checkbox

Loading...
<div class="w-full max-w-md">
    <twig:Field:Group>
        <twig:Field:Set>
            <twig:Field:Legend variant="label">
                Show these items on the desktop
            </twig:Field:Legend>
            <twig:Field:Description>
                Select the items you want to show on the desktop.
            </twig:Field:Description>
            <twig:Field:Group class="gap-3">
                <twig:Field orientation="horizontal">
                    <twig:Checkbox id="finder-pref-9k2-hard-disks-ljj" />
                    <twig:Field:Label
                        for="finder-pref-9k2-hard-disks-ljj"
                        class="font-normal"
                        checked
                    >
                        Hard disks
                    </twig:Field:Label>
                </twig:Field>
                <twig:Field orientation="horizontal">
                    <twig:Checkbox id="finder-pref-9k2-external-disks-1yg" />
                    <twig:Field:Label
                        for="finder-pref-9k2-external-disks-1yg"
                        class="font-normal"
                    >
                        External disks
                    </twig:Field:Label>
                </twig:Field>
                <twig:Field orientation="horizontal">
                    <twig:Checkbox id="finder-pref-9k2-cds-dvds-fzt" />
                    <twig:Field:Label
                        for="finder-pref-9k2-cds-dvds-fzt"
                        class="font-normal"
                    >
                        CDs, DVDs, and iPods
                    </twig:Field:Label>
                </twig:Field>
                <twig:Field orientation="horizontal">
                    <twig:Checkbox id="finder-pref-9k2-connected-servers-6l2" />
                    <twig:Field:Label
                        for="finder-pref-9k2-connected-servers-6l2"
                        class="font-normal"
                    >
                        Connected servers
                    </twig:Field:Label>
                </twig:Field>
            </twig:Field:Group>
        </twig:Field:Set>
        <twig:Field:Separator />
        <twig:Field orientation="horizontal">
            <twig:Checkbox id="finder-pref-9k2-sync-folders-nep" checked />
            <twig:Field:Content>
                <twig:Field:Label for="finder-pref-9k2-sync-folders-nep">
                    Sync Desktop & Documents folders
                </twig:Field:Label>
                <twig:Field:Description>
                    Your Desktop & Documents folders are being synced with iCloud Drive. You can access them from other devices.
                </twig:Field:Description>
            </twig:Field:Content>
        </twig:Field>
    </twig:Field:Group>
</div>

Switch

Loading...
<div class="w-full max-w-md">
    <twig:Field orientation="horizontal">
        <twig:Field:Content>
            <twig:Field:Label for="2fa">Multi-factor authentication</twig:Field:Label>
            <twig:Field:Description>
                Enable multi-factor authentication. If you do not have a two-factor device, you can use a one-time code sent to your email.
            </twig:Field:Description>
        </twig:Field:Content>
        <twig:Switch id="2fa" />
    </twig:Field>
</div>

Field group

Loading...
<div class="w-full max-w-md">
    <twig:Field:Group>
        <twig:Field:Set>
            <twig:Field:Label>Responses</twig:Field:Label>
            <twig:Field:Description>
                Get notified when ChatGPT responds to requests that take time, like research or image generation.
            </twig:Field:Description>
            <twig:Field:Group data-slot="checkbox-group">
                <twig:Field orientation="horizontal">
                    <twig:Checkbox id="push" checked disabled />
                    <twig:Field:Label for="push" class="font-normal">
                        Push notifications
                    </twig:Field:Label>
                </twig:Field>
            </twig:Field:Group>
        </twig:Field:Set>
        <twig:Field:Separator />
        <twig:Field:Set>
            <twig:Field:Label>Tasks</twig:Field:Label>
            <twig:Field:Description>
                Get notified when tasks you've created have updates. <a href="#">Manage tasks</a>
            </twig:Field:Description>
            <twig:Field:Group data-slot="checkbox-group">
                <twig:Field orientation="horizontal">
                    <twig:Checkbox id="push-tasks" />
                    <twig:Field:Label for="push-tasks" class="font-normal">
                        Push notifications
                    </twig:Field:Label>
                </twig:Field>
                <twig:Field orientation="horizontal">
                    <twig:Checkbox id="email-tasks" />
                    <twig:Field:Label for="email-tasks" class="font-normal">
                        Email notifications
                    </twig:Field:Label>
                </twig:Field>
            </twig:Field:Group>
        </twig:Field:Set>
    </twig:Field:Group>
</div>

API Reference

Field

Prop Type Description
orientation 'vertical'|'horizontal'|'responsive' The orientation of the field, default to vertical
Block Description
content The default block

Field:Content

Block Description
content The default block

Field:Description

Block Description
content The default block

Field:Error

Prop Type Description
errors array A list of errors (string or objects with a message field), defaults to []
Block Description
content The default block

Field:Group

Block Description
content The default block

Field:Label

Block Description
content The default block

Field:Legend

Prop Type Description
variant 'legend'|'label' The variant, default to legend
Block Description
content The default block

Field:Separator

Block Description
content The default block

Field:Set

Block Description
content The default block

Field:Title

Block Description
content The default block