Textarea

Displays a form textarea or a component that looks like a textarea.

Loading...
<div class="*:max-w-xs contents">
    <twig:Textarea placeholder="Type your message here." />
</div>

Installation

bin/console ux:install textarea --kit shadcn

That's it!

Install the following Composer dependencies:

composer require tales-from-a-dev/twig-tailwind-extra:^1.0.0 twig/extra-bundle twig/html-extra:^3.12.0

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

templates/components/Textarea.html.twig
{# @block content The initial textarea value #}
<textarea
    data-slot="textarea"
    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 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</textarea>
templates/components/Button.html.twig
{# @prop variant 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant. Defaults to `default` #}
{# @prop size 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size. Defaults to `default` #}
{# @prop as 'button' The HTML tag to render. Defaults to `button` #}
{# @block content The button label and/or icon #}
{%- props variant = 'default', size = 'default', 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 [a]: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-secondary/80 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 ltr:has-data-[icon=inline-end]:pr-2 rtl:has-data-[icon=inline-end]:pe-2 ltr:has-data-[icon=inline-start]:pl-2 rtl: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 ltr:has-data-[icon=inline-end]:pr-1.5 rtl:has-data-[icon=inline-end]:pe-1.5 ltr:has-data-[icon=inline-start]:pl-1.5 rtl: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 ltr:has-data-[icon=inline-end]:pr-1.5 rtl:has-data-[icon=inline-end]:pe-1.5 ltr:has-data-[icon=inline-start]:pl-1.5 rtl:has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 ltr:has-data-[icon=inline-end]:pr-2 rtl:has-data-[icon=inline-end]:pe-2 ltr:has-data-[icon=inline-start]:pl-2 rtl: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="button"
    data-variant="{{ variant }}"
    data-size="{{ size }}"
    class="{{ style.apply({variant: variant, size: size}, attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Field.html.twig
{# @prop orientation 'vertical'|'horizontal'|'responsive' The layout direction of the field. Defaults to `vertical` #}
{# @block content The field content, typically includes `Field:Label` and form input(s) #}
{%- props orientation = 'vertical' -%}
{%- set style = html_cva(
    base: 'group/field flex w-full gap-2 data-[invalid=true]:text-destructive',
    variants: {
        orientation: {
            vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
            horizontal: 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto 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:has-[>[data-slot=field-content]]:items-start @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @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>
templates/components/Field/Content.html.twig
{# @block content The input and supplementary elements like `Field:Description` and `Field:Error` #}
<div
    data-slot="field-content"
    class="{{ ('group/field-content flex flex-1 flex-col gap-0.5 leading-snug ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Field/Description.html.twig
{# @block content The helper text describing the field #}
<p
    data-slot="field-description"
    class="{{ ('ltr:text-left rtl:text-start text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5 last:mt-0 nth-last-2:-mt-1 [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</p>
templates/components/Field/Error.html.twig
{# @prop errors array A list of error messages (strings or objects with a `message` property). Defaults to `[]` #}
{# @block content Custom error content, overrides the `errors` prop if provided #}
{%- 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="ltr:ml-4 rtl:ms-4 flex list-disc flex-col gap-1">
                {%- for message in messages|default([]) -%}
                    <li>{{ message }}</li>
                {%- endfor -%}
            </ul>
        {%- endif -%}
    </div>
{%- endif -%}
templates/components/Field/Group.html.twig
{# @block content The grouped fields, typically multiple `Field` components #}
<div
    data-slot="field-group"
    class="{{ ('group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Field/Label.html.twig
{# @block content The label text for the field #}
<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-checked:border-primary/30 has-data-checked:bg-primary/5 has-checked:border-primary/30 has-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 dark:has-checked:border-primary/20 dark:has-checked:bg-primary/10 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ ...attributes }}
>
    {{- block(outerBlocks.content) -}}
</twig:Label>
templates/components/Field/Legend.html.twig
{# @prop variant 'legend'|'label' The text size variant. Defaults to `legend` #}
{# @block content The legend text for a fieldset #}
{%- props variant = 'legend' -%}
<legend
    data-slot="field-legend"
    data-variant="{{ variant }}"
    class="{{ ('mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</legend>
templates/components/Field/Separator.html.twig
{# @block content Optional text displayed in the center of the separator #}
{%- 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="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
            data-slot="field-separator-content"
        >
            {{ content }}
        </span>
    {%- endif -%}
</div>
templates/components/Field/Set.html.twig
{# @block content The fieldset content, typically includes `Field:Legend` and `Field` components #}
<fieldset
    data-slot="field-set"
    class="{{ ('flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</fieldset>
templates/components/Field/Title.html.twig
{# @block content The title text for the field (non-label variant) #}
<div
    data-slot="field-label"
    class="{{ ('flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Separator.html.twig
{# @prop orientation 'horizontal'|'vertical' The separator orientation. Defaults to `horizontal` #}
{# @prop decorative boolean Whether the separator is purely decorative (not semantic). Defaults to `true` #}
{%- props orientation = 'horizontal', decorative = true -%}
<div
    class="{{ ('shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes.defaults({
        'data-slot': 'separator',
        'data-orientation': orientation,
        role: decorative ? 'none' : 'separator',
        'aria-orientation': decorative ? false : orientation,
    }) }}
></div>
templates/components/Label.html.twig
{# @block content The label text for a form control #}
<label
    data-slot="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.without('class') }}
>
    {%- block content %}{% endblock -%}
</label>

Happy coding!

Usage

<twig:Textarea />

Examples

Field

Use Field, Field:Label, and Field:Description to create a textarea with a label and description.

Loading...
<div class="*:max-w-xs contents">
    <twig:Field>
        <twig:Field:Label for="textarea-message">Message</twig:Field:Label>
        <twig:Field:Description>Enter your message below.</twig:Field:Description>
        <twig:Textarea id="textarea-message" placeholder="Type your message here." />
    </twig:Field>
</div>

Disabled

Use the disabled attribute to disable the textarea. To style the disabled state, add the data-disabled attribute to the Field component.

Loading...
<div class="*:max-w-xs contents">
    <twig:Field data-disabled>
        <twig:Field:Label for="textarea-disabled">Message</twig:Field:Label>
        <twig:Textarea id="textarea-disabled" placeholder="Type your message here." disabled />
    </twig:Field>
</div>

Invalid

Use the aria-invalid attribute to mark the textarea as invalid. To style the invalid state, add the data-invalid attribute to the Field component.

Loading...
<div class="*:max-w-xs contents">
    <twig:Field data-invalid>
        <twig:Field:Label for="textarea-invalid">Message</twig:Field:Label>
        <twig:Textarea id="textarea-invalid" placeholder="Type your message here." aria-invalid="true" />
        <twig:Field:Description>Please enter a valid message.</twig:Field:Description>
    </twig:Field>
</div>

Button

Pair with Button to create a textarea with a submit button.

Loading...
<div class="*:max-w-xs contents">
    <div class="grid w-full gap-2">
        <twig:Textarea placeholder="Type your message here." />
        <twig:Button>Send message</twig:Button>
    </div>
</div>

RTL

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

Loading...
<div class="*:max-w-xs contents">
    <div class="flex flex-col gap-8 w-full">
        {# Arabic #}
        <twig:Field dir="rtl">
            <twig:Field:Label for="feedback-ar" dir="rtl">التعليقات</twig:Field:Label>
            <twig:Textarea id="feedback-ar" placeholder="تعليقاتك تساعدنا على التحسين..." dir="rtl" rows="4" />
            <twig:Field:Description dir="rtl">شاركنا أفكارك حول خدمتنا.</twig:Field:Description>
        </twig:Field>

        {# Hebrew #}
        <twig:Field dir="rtl">
            <twig:Field:Label for="feedback-he" dir="rtl">משוב</twig:Field:Label>
            <twig:Textarea id="feedback-he" placeholder="המשוב שלך עוזר לנו להתקדם..." dir="rtl" rows="4" />
            <twig:Field:Description dir="rtl">שתף את מחשבותיך על השירות שלנו.</twig:Field:Description>
        </twig:Field>
    </div>

</div>

API Reference

Component Textarea

Block Description
content The initial textarea value