Styleframe Logo
Forms

Textarea

A multi-line text-field component with a wrapper-owned visual surface, inline prefix/suffix addons, a resize axis, and invalid/disabled/readonly states. Supports light, dark, and neutral colors, default/soft/ghost styles, and three sizes through the recipe system.

Overview

The Textarea is a multi-line text-field container used to capture longer user input. It mirrors the Input recipe and is composed of three recipe parts:

  • useTextareaRecipe() — the wrapper that owns the visual field: border, background, padding, and the :focus-within ring. It also carries the resize axis.
  • useTextareaPrefixRecipe() — an inline leading addon that sits inside the field, sharing its surface.
  • useTextareaSuffixRecipe() — an inline trailing addon that sits inside the field, sharing its surface.

Each composable creates a fully configured recipe with color, variant, and size options — plus compound variants on the wrapper that handle every color-variant combination and the invalid, disabled, and readonly states automatically.

To join a textarea with a button or other bordered control as an attached block, wrap them in a Field Group rather than reaching for a built-in slot.

The Textarea recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.

Why use the Textarea recipe?

The Textarea recipe helps you:

  • Ship faster with sensible defaults: Get 3 colors, 3 visual styles, 3 sizes, a resize axis, and three native state axes out of the box with a single set of composable calls.
  • Match your single-line fields: Textarea shares the Input recipe's color, variant, size, and state system, so multi-line and single-line fields look identical in a form.
  • Control resizing: The resize axis maps to the native resize property on the underlying <textarea>, with a safe vertical default that won't break your layout.
  • Maintain consistency: Compound variants ensure every color-variant combination and every state follows the same design rules, including dark mode overrides.
  • Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.
  • Stay type-safe: Full TypeScript support means your editor catches invalid color, variant, size, resize, or state values at compile time.

Usage

Register the recipes

Add the Textarea recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:

src/components/textarea.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    useTextareaRecipe,
    useTextareaPrefixRecipe,
    useTextareaSuffixRecipe,
} from '@styleframe/theme';

const s = styleframe();

const textarea = useTextareaRecipe(s);
const textareaPrefix = useTextareaPrefixRecipe(s);
const textareaSuffix = useTextareaSuffixRecipe(s);

export default s;

Build the component

Import the textarea, textareaPrefix, and textareaSuffix runtime functions from the virtual module. The wrapper paints the visual field and the nested .textarea-field is a transparent native <textarea> that inherits typography. The resize axis controls the nested textarea's resize handle, and the invalid, disabled, and readonly axes accept booleans directly:

src/components/Textarea.tsx
import { textarea, textareaPrefix, textareaSuffix } from "virtual:styleframe";

interface TextareaProps {
    color?: "light" | "dark" | "neutral";
    variant?: "default" | "soft" | "ghost";
    size?: "sm" | "md" | "lg";
    resize?: "none" | "vertical" | "horizontal" | "both";
    invalid?: boolean;
    disabled?: boolean;
    readonly?: boolean;
    prefix?: React.ReactNode;
    suffix?: React.ReactNode;
}

export function Textarea({
    color = "neutral",
    variant = "default",
    size = "md",
    resize = "vertical",
    invalid = false,
    disabled = false,
    readonly = false,
    prefix,
    suffix,
    ...textareaProps
}: TextareaProps & React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
    const wrapper = textarea({ color, variant, size, resize, invalid, disabled, readonly });

    return (
        <span className={wrapper}>
            {prefix && <span className={textareaPrefix({ size })}>{prefix}</span>}
            <textarea
                className="textarea-field"
                disabled={disabled}
                readOnly={readonly}
                aria-invalid={invalid}
                {...textareaProps}
            />
            {suffix && <span className={textareaSuffix({ size })}>{suffix}</span>}
        </span>
    );
}

See it in action

Colors

The Textarea recipe includes 3 color variants: light, dark, and neutral. Like the Input, Card, and ChatMessage recipes, Textarea uses neutral-spectrum colors designed for content surfaces rather than status communication — a field's color reflects its surface, not a state. The wrapper combines each color with every visual style variant through compound variants, so you get consistent, predictable styling across all combinations — including dark mode overrides.

The neutral color adapts automatically: it uses a light appearance in light mode and a dark appearance in dark mode, making it the safest default for general-purpose forms.

Color Reference

ColorTokenUse Case
light@color.white / @color.gray-*Light surfaces, stays light in dark mode
dark@color.gray-900Dark surfaces, stays dark in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use neutral as your default textarea color. It adapts to the user's color scheme automatically, so you don't need to manage light and dark variants separately.

Variants

Three visual style variants control how the field surface is rendered. Each variant is combined with the selected color through compound variants, so you always get the correct background, text, and border colors for your chosen color.

Default

Opaque surface with a visible border — the standard bordered text field. Sits above the page with a solid background and reveals a primary-colored focus ring on :focus-within.

Soft

Tinted gray background with a matching border. A gentler, lower-contrast field that blends into dense forms.

Ghost

No background and no border until interaction. The field shows only the focus ring on :focus-within, making it ideal for inline editing and borderless layouts.

Sizes

Three size variants from sm to lg control the field's font size, padding, and border radius. The prefix and suffix addons share the same size axis, scaling their font size, inner padding, and gap to stay aligned with the field.

Size Reference

SizeFont SizePadding (V / H)Border Radius
sm@font-size.xs@0.375 / @0.625@border-radius.sm
md@font-size.sm@0.5 / @0.75@border-radius.md
lg@font-size.md@0.625 / @0.875@border-radius.md
Good to know: Use the native rows attribute on the nested <textarea> to set the initial height. The recipe controls the surface (border, padding, radius) and typography; rows and the resize axis control the height.

Resize

The resize axis maps to the native CSS resize property on the nested <textarea>. Because resizing must apply to the real control rather than the wrapper, each value adds a marker class on the wrapper (-resize-vertical, ...) that the recipe maps onto the .textarea-field.

ValueBehavior
noneThe field cannot be resized
verticalThe field can be resized vertically (default)
horizontalThe field can be resized horizontally
bothThe field can be resized in both directions
Pro tip: Keep the default vertical for most forms. It lets users expand the field for longer content without letting horizontal resizing break your layout. Use none for fixed-height fields and both only when the surrounding layout can absorb width changes.

States

The wrapper exposes three boolean state axes that map directly to the equivalent native <textarea> attributes. Pass each as a boolean — the recipe also accepts the "true" / "false" string keys, but the boolean form is simpler (see Build the component).

Invalid

Overrides the border and the focus ring to the error color. Set it alongside aria-invalid on the native textarea so assistive technology hears the error.

Disabled

Dims the field to 0.5 opacity, switches the cursor to not-allowed, and blocks pointer interaction (including the resize handle). Mirror it with the native disabled attribute so the textarea is also removed from the tab order.

Read-only

Applies a subtle background shift and a default cursor while keeping the value selectable. Mirror it with the native readonly attribute so the value is submitted but not editable.

Good to know:invalid is applied before readonly and disabled in the compound variant order, so an invalid field keeps its error border even when it is also read-only, and a disabled field still dims on top of any other state.

Addons

Textarea supports inline addons (prefix, suffix) that render inside the field and share its surface — with the wrapper top-aligned, they anchor to the top of the field. Use them for icons or character counters. To attach an interactive block such as a button outside the field, group the controls with a Field Group instead.

Prefix and suffix (inline)

The prefix and suffix sit inside the wrapper, to the left and right of the text, sharing the field's background and focus ring. They are size-aware and use a muted text color.

Attached controls (field group)

To attach a button, select, or other bordered control to the textarea, place them as direct children of a Field Group. A vertical group stacks the action beneath the field; the group flattens the border radius and inner border at the seam so they read as one joined unit. Set resize="none" on the textarea so the joined edge stays tidy.

Pro tip: Reach for prefix/suffix when the addon is decorative or informational (an icon, a counter) and lives inside the field. Reach for a Field Group when the addon is an interactive control (a button, a dropdown) attached outside the field.

Anatomy

The Textarea is composed of three independent recipes that build a standalone field with inline addons:

PartRecipeRole
FielduseTextareaRecipe()The .textarea wrapper — owns the visual field (border, background, padding, :focus-within ring) and the resize axis, and contains the transparent nested .textarea-field textarea
PrefixuseTextareaPrefixRecipe()Leading inline addon inside the field — icons or small affordances
SuffixuseTextareaSuffixRecipe()Trailing inline addon inside the field — icons, character counters

The nested .textarea-field element is the real <textarea>: it is transparent, has no border or padding of its own, and inherits typography and color from the wrapper. The wrapper owns the entire visual field via :focus-within, so the focus ring appears whenever the inner textarea is focused.

<!-- Standalone field with inline addons -->
<span class="textarea(...)">
    <span class="textareaPrefix(...)"></span>
    <textarea class="textarea-field" rows="4"></textarea>
    <span class="textareaSuffix(...)">0/280</span>
</span>

<!-- Field attached to a button via a vertical field group -->
<div class="fieldGroup(...)">
    <span class="textarea(...)"><textarea class="textarea-field"></textarea></span>
    <button class="button(...)">Send</button>
</div>
Pro tip: Use useTextareaRecipe() on its own (with optional prefix/suffix) for most textareas, and reach for a Field Group only when you attach interactive blocks.

Accessibility

  • Pair the invalid state with aria-invalid. The error border is a visual cue only. Set aria-invalid="true" on the native textarea and reference an error message with aria-describedby so screen readers announce the problem.
  • Mirror states on the native textarea. Set the real disabled and readonly attributes in addition to the recipe state, so the field is correctly removed from (or kept in) the tab order and submitted accordingly.
  • Label every field. Associate a <label> with the nested textarea via for/id, or wrap the textarea in the label. The recipe styles the surface but does not provide a name.
  • Give inline addons text alternatives. A prefix or suffix that conveys meaning (a character counter, an icon) should be exposed to assistive technology — keep it as readable text, or add an aria-label to the textarea that includes it.
  • Verify contrast ratios. The dark color places light text on a dark surface. Default tokens meet WCAG AA 4.5:1 contrast. If you override colors, verify with the WebAIM Contrast Checker.

Customization

Overriding Defaults

Each textarea composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change:

src/components/textarea.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useTextareaRecipe } from '@styleframe/theme';

const s = styleframe();

const textarea = useTextareaRecipe(s, {
    base: {
        borderRadius: '@border-radius.lg',
    },
    defaultVariants: {
        color: 'neutral',
        variant: 'soft',
        size: 'lg',
        resize: 'none',
    },
});

export default s;

Filtering Variants

If you only need a subset of the available variants, use the filter option to limit which values are generated. This reduces the output CSS and keeps your component API focused:

src/components/textarea.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useTextareaRecipe } from '@styleframe/theme';

const s = styleframe();

// Only generate the neutral color with the default and soft styles
const textarea = useTextareaRecipe(s, {
    filter: {
        color: ['neutral'],
        variant: ['default', 'soft'],
    },
});

export default s;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

API Reference

useTextareaRecipe(s, options?)

Creates the textarea wrapper recipe — the .textarea element that owns the visual field. Registers a nested .textarea-field selector for the transparent native textarea and the resize marker selectors. Owns the full compound matrix: 9 color-variant combinations, 3 state overrides, and 4 resize markers.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the field wrapper
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.compoundVariantsCompoundVariant[]Custom compound variant definitions for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantdefault, soft, ghostdefault
sizesm, md, lgmd
resizenone, vertical, horizontal, bothvertical
invalidtrue, falsefalse
disabledtrue, falsefalse
readonlytrue, falsefalse

useTextareaPrefixRecipe(s, options?)

Creates the inline prefix recipe — a leading addon rendered inside the field, sharing its surface. Size-aware (font size, inner padding, gap) with a muted text color. Accepts the same parameters as useTextareaRecipe.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

useTextareaSuffixRecipe(s, options?)

Creates the inline suffix recipe — a trailing addon rendered inside the field, sharing its surface. Mirrors the prefix recipe with trailing inner padding. Accepts the same parameters as useTextareaRecipe.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

To attach interactive blocks outside the field, see the Field Group recipe.

Learn more about recipes →

Best Practices

  • Pass size consistently: Spread the same size to the wrapper and to every prefix and suffix part so the whole field scales together.
  • Set the initial height with rows: Use the native rows attribute for the starting height and the resize axis to control whether (and how) users can grow the field.
  • Keep resize vertical: The default vertical lets users expand for longer content without breaking the layout. Reserve horizontal and both for layouts that can absorb width changes.
  • Pass states as booleans: The invalid, disabled, and readonly axes accept a boolean directly — no "true" / "false" conversion needed (the string keys still work if you prefer them).
  • Mirror states on the native textarea: Always set the real disabled / readonly attributes and aria-invalid in addition to the recipe state.
  • Use neutral for general forms: The neutral color adapts to light and dark mode automatically, making it the safest default.
  • Filter what you don't need: If your forms use only one color or two variants, pass a filter option to reduce generated CSS.

FAQ