Use a memorable, unique name customers will recognise.
Default + label + help
Overview
The Text field is the canonical single-line input. Its signature move is the always-floating label: the field name sits at the top of the container in small grey text; the value sits below it in body type. No animation, no Material-style transition — the label is just always there, in its place. Use it everywhere a form asks the user for one line.
One component, six native types
TextField takes a type prop (text / email / url / search / password / tel / number). Four preconfigured wrappers — EmailField, UrlField, PasswordField, NumberField— set the right type, autocomplete, inputMode, and behaviour up-front so you don't have to remember the combination at every call site. For free-text or filtered search use Search — it's a dedicated combobox, not a TextField variant.
States
Six visible states. Hover, active, and focus are pseudo-class driven — they come for free on the real components. Held still here for review.
Empty
Default resting state. Label visible at top, value area blank.
Filled
User has typed. Value sits below the floating label.
Use a memorable, unique name.
Hover
Pointer over the container; border darkens to border-outline-hover. Hover the field to see it on this page.
Tab into the field with the keyboard to see the ring.
Focus
Keyboard or click focus. Border switches to border-foreground; keyboard adds the ring-ring ring. Click into the field below to confirm.
Set during onboarding; not editable here.
Disabled
Inert. Container fills bg-disabled; text drops to text-disabled-foreground.
A store with this name already exists.
Invalid
Container fills bg-destructive, border switches to border-destructive-border, error message replaces help text.
Variants
One component, five type-specific wrappers. Reach for the wrapper that matches the data — the wrapper sets autoComplete, inputMode, and (where relevant) the affordance (e.g. password reveal eye).
Use a memorable name customers will recognise.
type="text"
The default. Plain single-line text.
We'll send a one-time link to sign in.
<EmailField>
type=email + autoComplete=email. Mobile keyboards switch to the @ layout.
Public URL to your store, blog, or landing page.
<UrlField>
type=url + inputMode=url + autoComplete=url. The browser validates protocol on submit.
At least 12 characters.
<PasswordField>
type=password + an eye toggle that reveals the value. Toggle is keyboard-accessible.
Hour in 24-hour clock.
<NumberField>
type=number + tabular-nums. Mobile keyboards show the numeric pad.
Money is a separate page
MoneyField deserves its own treatment — European format, slashed zero, currency suffix, and the hero metric size. See Money field for the rules.
Compositions
Prefix, suffix, help text, error message — the same four slots handle every common composition. Reach for them via props rather than nesting buttons inside the input's children.
Prefix
Use prefix for ≤3-character adornments — currency codes, country dialling codes, sigils. Anything longer competes with the value and belongs in a separate field.
Copy
Set `copy` (or use <CopyField>) to lift a value out of the form. The surface drops to disabled tokens; click anywhere to copy. The icon flips to a check for ~1.5 s on success.
Used to verify push events from Flatpay reach you intact.
Help text
Belongs below the field. Sets context the label can't carry alone.
Couldn't find this repo. Check the owner/name spelling.
Error message
Set the error prop to flip the surface red, set aria-invalid, and replace the help text.
Multiline
For values that need more than one line — a description, a cancellation reason, a long note — reach for TextArea. Same visual language as TextField; rendered as a real <textarea> element with native vertical resize.
Visible to customers on the storefront.
<TextArea>
Multi-line text. Same visual language as TextField — floating label, 8 px radius. Vertical resize is on by default.
Locked height
Set resize="none" when the surrounding layout shouldn't reflow.
Anatomy
Five named parts. Only the box is required — label, prefix / suffix, description, and error are opt-in.
Use a memorable name.
Container
Rounded surface (8 px). Carries the border, focus ring, and disabled / invalid background.
Label
Inter Tight 12 / Regular at 60% foreground. Sentence case. Always at the top of the container.
Value
Inter Tight 16 / Regular. The actual text the user types. Placeholder colour drops to 40%.
Suffix slot
Optional. Currency code, copy button, eye toggle, search clear button. Inherits text colour from the container — set it via the
suffixprop.Help / error
Sits below the container. Help text in 60% foreground; error switches to
text-destructive-foregroundwith an alert glyph.
Behavior
- Click anywhere in the container focuses the input. The whole tile is wrapped in a
<label>tied to the input viahtmlFor. Don't intercept clicks on the label area. - Border darkens on focus, ring shows on keyboard only.
focus-withinswitches the border toborder-foregroundfor any focus;has-[:focus-visible]adds thering-ringring only when the user arrived via keyboard. - Help text and errors are linked via aria-describedby. The component generates the IDs, attaches them, and switches from
descriptiontoerrorwhen the error prop is set. Don't hand-roll a separate paragraph. - Password reveal is opt-in.
PasswordFieldships with the eye toggle on by default; passshowPasswordToggle={false}for fields where revealing the value would be unsafe (keypad entry, shared screens). - Search clear button only appears when there's content. No empty × glyph cluttering the resting state. The button focuses the field after clearing.
Accessibility
- Native semantics. Renders an
<input>inside a<label>viahtmlFor. No custom ARIA needed for the basic field. - Description and error are linked via
aria-describedby. When the error prop is set, the input also getsaria-invalid="true". - Use the right type. Mobile keyboards and assistive tech adapt to
type="email",type="url", andinputMode="decimal". Reach for the typed wrappers — they pre-set everything. - Autocomplete tokens. The wrappers set sensible defaults (
autoComplete="email",autoComplete="current-password"). Override per-field when the form context calls for it (e.g.new-passwordon a registration form). - Don't hide the label. The always-floating label is the design — never set it to
sr-onlyor rely on the placeholder for context. Placeholders disappear on type; labels stay.
Code
Import from @flatpay-dk/ui. Renders a real native input — every standard input attribute (name, required, pattern,minLength) passes through.
tsx
import {
TextField,
EmailField,
UrlField,
PasswordField,
NumberField,
TextArea,
} from "@flatpay-dk/ui";
// Default — text input
<TextField
label="Store name"
description="Use a memorable name customers will recognise."
name="store_name"
required
/>
// Email — pre-set type, autoComplete, mobile keyboard
<EmailField
label="Email address"
placeholder="you@flatpay.com"
description="We'll send a one-time link to sign in."
/>
// Password — eye toggle on by default
<PasswordField label="Password" autoComplete="new-password" />
// URL — type=url + inputMode=url
<UrlField label="Website" placeholder="https://example.com" />
// Number — tabular-nums + decimal keyboard
<NumberField label="Daily settlement cutoff" min={0} max={23} />
// Prefix slot — ≤3-character adornments only
<TextField
label="Phone number"
type="tel"
prefix={<span className="font-mono text-[13px]">+45</span>}
defaultValue="20 12 34 56"
/>
// Multiline
<TextArea
label="Description"
rows={4}
description="Visible to customers on the storefront."
/>
// Invalid + error message
<TextField
label="Repository"
error="Couldn't find this repo. Check the owner/name spelling."
/>Best practices
A handful of habits keep forms predictable across the product.
Do
Use the typed wrapper that matches your data — it sets autoComplete, inputMode, and the right keyboard for free.
Don't
Don't hide the label. The always-floating label is the design — placeholders disappear on type.
Couldn't find this repo.
Do
Set the error prop instead of a sibling paragraph. The component handles aria-invalid and the alert glyph.
Couldn't find this repo.
Don't
Don't lean on red text below an unstyled field. The user shouldn't have to read the message to know which field failed.
Do
Use prefix for short adornments — country code, currency, sigil. Three characters or fewer.
Don't
Don't pack co-text into the prefix. A protocol or domain belongs in a separate field — anything longer than 3 characters fights the value.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "text" | "email" | "url" | "search" | "password" | "tel" | "number" | "text" | Native input type. Reach for the typed wrappers (EmailField, etc.) to set this and the matching defaults at the same time. |
| label | ReactNode | — | Floating label rendered at the top of the container. Sentence case. Always visible. |
| description | ReactNode | — | Help text below the container. Hidden when the error prop is set. |
| error | ReactNode | — | When set, flips the surface to error tones, sets aria-invalid, and replaces the help text with the alert message. |
| prefix | ReactNode | — | ≤3-character adornment rendered before the value — icon, currency code, country dialling code, sigil. Text content longer than 3 characters logs a dev-mode warning. Inherits text colour. |
| suffix | ReactNode | — | Element rendered after the input value (currency code, unit, eye toggle). Ignored when `copy` is set — the copy button takes over the trailing slot. |
| size | "md" | "hero" | "md" | Vertical density and value typography. Hero is reserved for big metric inputs (used by MoneyField for funding-amount surfaces). |
| showPasswordToggle | boolean | false (true on PasswordField) | When type=password, render an eye toggle that switches the visible value between dots and plaintext. |
| showClearButton | boolean | false | Render an × button when the field has content. Clicking it clears the value and refocuses the field. |
| copy | boolean | false | Lift the value out of the form. Forces readOnly, takes over the trailing slot with a 24 px copy button, and applies the readonly-copy surface (disabled bg + border tokens, but value text in primary). Clicking anywhere on the field copies the value; the icon flips to a check for ~1.5 s. `<CopyField>` is the ergonomic shortcut. `disabled` wins over `copy`. Ignored at size=hero. |
| ...rest | InputHTMLAttributes<HTMLInputElement> | — | All standard input attributes (name, required, pattern, autoComplete, etc.) pass through to the underlying input. |