Input
Overview
The Input is a text-field container used to capture single-line user input. It is composed of three recipe parts:
useInputRecipe()— the wrapper that owns the visual field: border, background, padding, and the:focus-withinring.useInputPrefixRecipe()— an inline leading addon that sits inside the field, sharing its surface.useInputSuffixRecipe()— 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 an input with a button, select, or other bordered control as an attached block, wrap them in a Field Group rather than reaching for a built-in slot.
The Input 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 Input recipe?
The Input recipe helps you:
- Ship faster with sensible defaults: Get 3 colors, 3 visual styles, 3 sizes, and three native state axes out of the box with a single set of composable calls.
- Compose rich fields: Inline prefix/suffix addons share the field's size axis, so icons, currency symbols, and units stay aligned with the text.
- 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, or state values at compile time.
- Integrate with your tokens: Every value references the design tokens preset, so theme changes propagate automatically.
Usage
Register the recipes
Add the Input 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:
import { styleframe } from 'virtual:styleframe';
import {
useInputRecipe,
useInputPrefixRecipe,
useInputSuffixRecipe,
} from '@styleframe/theme';
const s = styleframe();
const input = useInputRecipe(s);
const inputPrefix = useInputPrefixRecipe(s);
const inputSuffix = useInputSuffixRecipe(s);
export default s;
Build the component
Import the input, inputPrefix, and inputSuffix runtime functions from the virtual module. The wrapper paints the visual field and the nested .input-field is a transparent native <input> that inherits typography. The invalid, disabled, and readonly axes accept booleans directly:
import { input, inputPrefix, inputSuffix } from "virtual:styleframe";
interface InputProps {
color?: "light" | "dark" | "neutral";
variant?: "default" | "soft" | "ghost";
size?: "sm" | "md" | "lg";
invalid?: boolean;
disabled?: boolean;
readonly?: boolean;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}
export function Input({
color = "neutral",
variant = "default",
size = "md",
invalid = false,
disabled = false,
readonly = false,
prefix,
suffix,
...inputProps
}: InputProps & React.InputHTMLAttributes<HTMLInputElement>) {
const wrapper = input({ color, variant, size, invalid, disabled, readonly });
return (
<span className={wrapper}>
{prefix && <span className={inputPrefix({ size })}>{prefix}</span>}
<input
className="input-field"
disabled={disabled}
readOnly={readonly}
aria-invalid={invalid}
{...inputProps}
/>
{suffix && <span className={inputSuffix({ size })}>{suffix}</span>}
</span>
);
}
See it in action
Colors
The Input recipe includes 3 color variants: light, dark, and neutral. Like the Card and ChatMessage recipes, Input 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
| Color | Token | Border (hover) | Use Case |
|---|---|---|---|
light | @color.white / @color.gray-* | @color.gray-450 (@color.gray-500) | Light surfaces, stays light in dark mode |
dark | @color.gray-900 | @color.gray-500 (@color.gray-450) | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | The light border in light mode, the dark border in dark mode | Default color, adapts to the current color scheme |
neutral as your default input 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. The border is at least 3:1 against the field and the page, so people can find the field before they focus it.
Soft
Tinted gray background with the same border as Default. A gentler field that sits well in dense forms.
Ghost
No background, and a bottom border in the field's border color with square bottom corners. The underline shows where the field is without boxing it in, which suits inline editing and low-chrome layouts. The focus ring appears on :focus-within.
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
| Size | Font Size | Padding (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 |
size to the wrapper and to each prefix/suffix part so the whole field scales together. The wrapper owns the radius and padding; the addons manage their own font size and gap.States
The wrapper exposes three boolean state axes that map directly to the equivalent native <input> 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, the ghost underline included. Set it alongside aria-invalid on the native input so assistive technology hears the error.
Disabled
Dims the field to 0.5 opacity and switches the cursor to not-allowed. Mirror it with the native disabled attribute, which blocks typing and removes the input from the tab order. If the suffix holds a clear or reveal button, disable it too, since the recipe doesn't block clicks on it.
Read-only
Switches the border to dashed, shifts the background to @color.gray-50 (@color.gray-800 in dark mode) and sets a default cursor while keeping the value selectable. The dashed edge tells people the value can't be changed, without leaning on the background tint alone. A ghost field that is read-only gets the tint and a dashed underline. Mirror it with the native readonly attribute so the value is submitted but not editable.
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
Input supports inline addons (prefix, suffix) that render inside the field and share its surface — use them for icons, currency symbols, and units. To attach an interactive block such as a button or select 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 caret, 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 input, place them as direct children of a Field Group. The group flattens the border radius and inner border at the seams, so the controls read as one joined unit — each child keeps its own styling.
prefix/suffix when the addon is decorative or informational (an icon, a unit) 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 Input is composed of three independent recipes that build a standalone field with inline addons:
| Part | Recipe | Role |
|---|---|---|
| Field | useInputRecipe() | The .input wrapper — owns the visual field (border, background, padding, :focus-within ring) and contains the transparent nested .input-field input |
| Prefix | useInputPrefixRecipe() | Leading inline addon inside the field — icons, currency symbols, units |
| Suffix | useInputSuffixRecipe() | Trailing inline addon inside the field — icons, clear or reveal buttons |
The nested .input-field element is the real <input>: 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 input is focused.
<!-- Standalone field with inline addons -->
<span class="input(...)">
<span class="inputPrefix(...)">$</span>
<input class="input-field" />
<span class="inputSuffix(...)">.00</span>
</span>
<!-- Field attached to a button via a field group -->
<div class="fieldGroup(...)">
<span class="input(...)"><input class="input-field" /></span>
<button class="button(...)">Search</button>
</div>
useInputRecipe() on its own (with optional prefix/suffix) for most inputs, and reach for a Field Group only when you attach interactive blocks.Accessibility
- Pair the
invalidstate witharia-invalid. The error border is a visual cue only. Setaria-invalid="true"on the native input and reference an error message witharia-describedbyso screen readers announce the problem. - Mirror states on the native input. Set the real
disabledandreadonlyattributes in addition to the recipe state, so the field is correctly removed from (or kept in) the tab order and submitted accordingly. - Give inline addons text alternatives. A prefix or suffix that conveys meaning (a currency symbol, a unit) should be exposed to assistive technology. Keep it as readable text, give it an
idand reference it from the input witharia-describedby. Avoid anaria-labelon the input: it replaces the visible label as the field's name, so the name can drift from what people see and say (WCAG 2.5.3). - Label every field. Associate a
<label>with the nested input viafor/id, or wrap the input in the label. The recipe styles the surface but does not provide a name. - The field border meets 3:1. Every border, the ghost underline included, is at least 3:1 against the field's background and the page background in light and dark mode, at rest and on hover. That's what WCAG 1.4.11 asks of a control's boundary, so people with low vision can find the field.
- Verify contrast ratios. The
darkcolor 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 input 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:
import { styleframe } from 'virtual:styleframe';
import { useInputRecipe } from '@styleframe/theme';
const s = styleframe();
const input = useInputRecipe(s, {
base: {
borderRadius: '@border-radius.lg',
},
defaultVariants: {
color: 'neutral',
variant: 'soft',
size: 'lg',
},
});
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:
import { styleframe } from 'virtual:styleframe';
import { useInputRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the neutral color with the default and soft styles
const input = useInputRecipe(s, {
filter: {
color: ['neutral'],
variant: ['default', 'soft'],
},
});
export default s;
API Reference
useInputRecipe(s, options?)
Creates the input wrapper recipe — the .input element that owns the visual field. Registers a nested .input-field selector for the transparent native input. Owns the full compound matrix: 9 color-variant combinations plus the 3 state overrides.
Parameters:
| Parameter | Type | Description |
|---|---|---|
s | Styleframe | The Styleframe instance |
options | DeepPartial<RecipeConfig> | Optional overrides for the recipe configuration |
options.base | VariantDeclarationsBlock | Custom base styles for the field wrapper |
options.variants | Variants | Custom variant definitions for the recipe |
options.defaultVariants | Record<keyof Variants, string> | Default variant values for the recipe |
options.compoundVariants | CompoundVariant[] | Custom compound variant definitions for the recipe |
options.filter | Record<string, string[]> | Limit which variant values are generated |
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | default, soft, ghost | default |
size | sm, md, lg | md |
invalid | true, false | false |
disabled | true, false | false |
readonly | true, false | false |
useInputPrefixRecipe(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 useInputRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
size | sm, md, lg | md |
useInputSuffixRecipe(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 useInputRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
size | sm, md, lg | md |
To attach interactive blocks outside the field, see the Field Group recipe.
Best Practices
- Pass
sizeconsistently: Spread the samesizeto the wrapper and to every prefix and suffix part so the whole field scales together. - Pass states as booleans: The
invalid,disabled, andreadonlyaxes accept a boolean directly — no"true"/"false"conversion needed (the string keys still work if you prefer them). - Mirror states on the native input: Always set the real
disabled/readonlyattributes andaria-invalidin addition to the recipe state. - Choose the right addon kind: Use
prefix/suffixfor decorative or informational content inside the field, and a Field Group for interactive blocks attached to it. - Use
neutralfor 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
filteroption to reduce generated CSS. - Override defaults at the recipe level: Set your most common combination as
defaultVariantsso component consumers write less code.
FAQ
.input wrapper owns the visual field — border, background, padding, and the :focus-within ring — while the nested .input-field is a transparent native <input> that inherits typography. This split lets inline prefix and suffix addons share the same surface as the typed text, and lets the focus ring respond to the inner input through :focus-within without extra JavaScript.prefix and suffix render inside the field and share its background and focus ring — use them for icons, currency symbols, and units. To attach an interactive control such as a button or select outside the field, wrap the input and the control in a Field Group, which flattens the border radii at the seams so they read as one joined unit.input({ invalid, disabled, readonly }). The recipe coerces the value to the matching "true" / "false" variant key, so the "true" / "false" strings work too — the boolean form is just simpler. Always set the matching native attribute (disabled, readonly) and aria-invalid so the field behaves correctly for keyboard and assistive-technology users.light, dark, and neutral to provide surface variations that work across all forms. Error communication is handled by the dedicated invalid state, which switches the border and focus ring to the error color.The wrapper recipe (useInputRecipe) maps each color-variant combination to specific styles. For example, when color is neutral and variant is default, the compound variant applies background: @color.white, borderColor: @color.gray-450, and color: @color.text, along with dark mode overrides. The recipe has 12 compound entries: 9 from the color × variant matrix (3 colors × 3 variants) plus 3 state overrides for invalid, readonly, and disabled.
filter option, compound variants that reference filtered-out values are automatically removed. For example, if you filter variant to only ['default', 'soft'], all compound variants matching ghost are excluded from the generated output. Default variants are also adjusted if they reference a removed value.@color.white, @border-radius.md, and @color.error through string refs. These tokens need to be defined in your Styleframe instance for the recipe to generate valid CSS. The easiest way is to use useDesignTokensPreset(s), but you can also define the required tokens manually.Field Group
A layout component for joining buttons, inputs, selects, and other bordered controls into a single unit with merged borders and shared orientation. Supports horizontal and vertical layouts plus full-width block mode through the recipe system.
Input Date
A segmented date input — a wrapper-owned field surface holding individually focusable date segments separated by muted glyphs, with light, dark, and neutral colors, default, soft, and ghost styles, three sizes, and invalid, disabled, and read-only states through the recipe system.