Form field
One form field in the GOV.UK order: label, hint, error, control, optional character count. It wires the ids so the control gets aria-describedby and aria-invalid, and hangs an error rule beside the field.
cmp_form_field_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_form_field_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-d61d686e3f410c5c80c4d76ea7678417633fba023b4cf4bc38278a961c354670
This component needs all 6 files. Download the ZIP
<!--
The field's name: a native <label for> with an optional marker in words, such as "(optional)",
inside it so the marker is part of the accessible name. As a page heading it sits inside an
<h1> or <h2>, so a one-question page does not say the question twice.
-->
<script lang="ts">
import type { FormFieldSize } from '../types';
interface Props {
/** The control's id. */
for: string;
/** Label text. */
text: string;
/** Marker text after the label, such as "(optional)". Omitted, no marker renders. */
marker?: string;
/** Visually hidden, still the control's accessible name. */
hidden?: boolean;
/** Wrap the label in a heading of this level. */
heading?: 1 | 2;
size?: FormFieldSize;
}
let { for: htmlFor, text, marker, hidden = false, heading, size = 'default' }: Props = $props();
</script>
{#snippet label()}
<label
for={htmlFor}
class={[
'block text-start break-words text-[var(--_ink)]',
hidden && 'sr-only',
heading && !hidden
? 'form-field__tracked text-2xl/7 font-semibold tracking-[-0.02em] text-balance'
: size === 'compact'
? 'text-[13px]/5 font-medium text-pretty'
: 'text-sm/5 font-medium text-pretty'
]}
>
<!-- A space the marker can wrap at, then the marker kept whole. -->
{marker ? `${text} ` : text}{#if marker}<span
class="font-normal tracking-normal whitespace-nowrap text-[var(--_muted)]">{marker}</span
>{/if}
</label>
{/snippet}
{#if heading === 1 && !hidden}
<h1 class="m-0">{@render label()}</h1>
{:else if heading === 2 && !hidden}
<h2 class="m-0">{@render label()}</h2>
{:else}
{@render label()}
{/if}
<style>
/* Arabic and Hebrew are never letter-spaced. */
.form-field__tracked:dir(rtl) {
letter-spacing: 0;
}
</style>
Usage#
On this pagePass the label, and hint and error when you have them, and render the control in the control snippet by spreading the attributes it receives: id, aria-describedby, aria-invalid, required and the field's control class. The field wires the ids and styles the control; it does not validate values, decide when to show an error, move focus, keep form state, trim text to the count's limit, or group radios and checkboxes (use a fieldset for those).
- Suggested location
src/lib/components/form-field-01
Limitations
- The field shows the error you pass; it never validates. Set error after your own validation, usually on submit, and pair it with an error summary at the top of the form.
- The control styling applies only when you spread
attributes.classonto a native input, select or textarea. Leave it off, or merge your own classes with it, to style the control yourself. - The drawn chevron applies to single, unsized selects; multiple and sized selects keep the platform's appearance.
- The count counts what you pass as
count.value(rounded to a whole, non-negative number); it does not read the control. Characters beyond the limit are allowed and shown as too many, never trimmed. - The count's announcement needs the browser; without JavaScript the visible count and the limit still render.
- The error rule hangs 12 px into the start gutter, so a container with overflow hidden needs at least that much padding.
- Not for groups of radios or checkboxes; those need a fieldset and legend.
Example
<script lang="ts">
import FormField from '$lib/components/form-field-01/FormField.svelte';
let email = $state('');
let error = $state<string>();
function submit(event: SubmitEvent) {
error = email.includes('@')
? undefined
: 'Enter an email address in the correct format, like name@example.com';
if (error) event.preventDefault();
}
</script>
<form method="post" novalidate onsubmit={submit} class="grid max-w-md gap-6">
<FormField label="Email address" hint="We send your sign-in link here." {error} required>
{#snippet control(attributes)}
<input {...attributes} type="email" name="email" autocomplete="email" bind:value={email} />
{/snippet}
</FormField>
<button type="submit">Send sign-in link</button>
</form>Form field#
One field in the GOV.UK order: label, hint, error, control, then an optional character count.
The field owns the ids. Your control receives them through the control snippet:
<FormField label="Due date" hint="For example, 14 11 2026" {error} required>
{#snippet control(attributes)}
<input {...attributes} name="due" inputmode="numeric" bind:value={due} />
{/snippet}
</FormField>attributes is { id, 'aria-describedby', 'aria-invalid', required, class }:
| Attribute | What it holds |
|---|---|
id |
The id prop, or one generated per instance. The label's for points at it. |
aria-describedby |
The hint, then the error, then the count's limit, only for those that render. |
aria-invalid |
'true' while error is set, otherwise absent (never 'false'). |
required |
The required prop. |
class |
The field's control styling for a native input, select or textarea. |
Styling the control#
Spread everything and the control is finished: 40 px tall (36 px compact from sm up, at
least 44 px on coarse pointers), 16 px text below sm so iOS does not zoom, a drawn chevron in
the muted token on a single select, and
disabled, read-only, invalid and autofill states. To set a width, merge a class:
<input {...attributes} class="{attributes.class} max-w-40" />To use your own control component, spread every attribute except class and style it
yourself; the component must forward id and the aria-* attributes to its focusable element.
Errors#
The field shows the error you pass. It never validates and never decides when to show one:
validate on submit, set error to a message that says what to do ("Enter a date in the past",
not "Invalid"), and put an error summary at the top of the form that links to each field. The
message is not a live region, because it is read with the control when focus arrives.
An error hangs a two-pixel rule 12 px into the start gutter beside the whole field, so a
container with overflow: hidden needs at least that much padding on its start side.
Markers#
Pass id only when your own code needs it; the part ids add -hint, -error and -limit to
it, so keep it unique on the page.
required shows requiredLabel ('(required)') after the label; optionalLabel shows on
fields that are not required. GOV.UK recommends leaving required fields unmarked and marking the
optional ones: set requiredLabel="" and optionalLabel="(optional)". Markers sit inside the
label, so they are part of the control's accessible name.
Character count#
Pass count={{ value: text.length, max: 280 }}. The limit sentence is read with the control,
the visible count follows the value, and a polite status repeats it one second after the value
stops changing. With countThreshold, the count appears only when that many characters or
fewer remain; its line is reserved until then. Nothing trims the value. Translate the wording
with countMessages:
countMessages={{
limit: (max) => `Vous pouvez saisir jusqu'à ${max} caractères`,
remaining: (n) => `Il vous reste ${n} caractères`,
over: (n) => `Vous avez ${n} caractères en trop`
}}Retoning#
On a #09090b band:
.band {
background: #09090b;
--form-field-accent: #fafafa;
--form-field-ink: #fafafa;
--form-field-muted: #a1a1aa;
--form-field-border: rgb(255 255 255 / 0.4);
--form-field-hairline: rgb(255 255 255 / 0.1);
--form-field-surface: #18181b;
--form-field-error: #f87171;
--form-field-color-scheme: dark;
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label text: the question or the name of the value. Always rendered as a <label for> the control. |
control | Snippet<[FormFieldControlAttributes]> | Yes | None | Renders the control. Spread the attributes onto it: { id, 'aria-describedby', 'aria-invalid', required, class }. |
hint | string | Snippet | No | None | Guidance under the label: a format or a constraint, not the label again. A snippet when it holds a link. |
error | string | No | None | The current validation message. When set, it renders between the hint and the control, the control gets aria-invalid, and a rule hangs beside the field. |
required | boolean | No | false | Passes native required to the control and shows requiredLabel. |
requiredLabel | string | No | '(required)' | Marker after the label of a required field, inside the accessible name. Set '' when you mark optional fields instead. |
optionalLabel | string | No | None | Marker after the label of a field that is not required, such as '(optional)'. Omitted, no marker renders. |
id | string | No | None | The control's id. Generated per instance with $props.id() when omitted or empty; the part ids add -hint, -error and -limit to it, so keep it unique. |
labelHidden | boolean | No | false | Hides the label visually; it stays the control's accessible name. For a search field whose button explains it. |
labelHeading | 1 | 2 | No | None | Wraps the label in an <h1> or <h2> at 24 px, for a page that asks one question. |
size | 'default' | 'compact' | No | 'default' | default: 40 px controls, 14 px labels, 13 px hints. compact: 36 px controls from sm up, 13 px labels, 12 px hints. Both use 16 px control text below sm (40 px tall there) and at least 44 px on coarse pointers. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before the error. Translate it with the page. |
errorIcon | boolean | No | true | Shows the warning mark beside the error, level with its first line. |
count | FormFieldCount | No | None | { value, max }: characters typed and the limit. Renders the count under the control and adds the limit to aria-describedby. |
countThreshold | number | No | None | Show the count only once this many characters or fewer remain. Its line is reserved meanwhile, so nothing moves when it appears. |
countMessages | Partial<FormFieldCountMessages> | No | None | The count's wording: limit(max), remaining(n) and over(n), each returning a sentence. Defaults to English. |
class | string | No | None | Extra classes for the field's root, such as a width or grid placement. |
Customization#
On this pageChange the words through props and retone the field through eight --form-field-* CSS variables. Hover edges, the placeholder and the disabled fill are mixed from those, so they follow any retone.
- Markers: GOV.UK recommends marking optional fields and leaving required ones unmarked. For that, set
requiredLabel='' on every required field andoptionalLabel='(optional)' on the optional ones. Keep markers in words; an asterisk alone is not read reliably. - Hints and errors: a hint gives the format or a constraint ('For example, 27 3 1985'), never the label again. An error says what to do ('Enter a date in the past'), not 'Invalid input'.
- Width: the control fills the field. Set the width on the field with class, or on the control by merging classes: <input {...attributes} class="{
attributes.class}max-w-40" /> for a date or a postcode. - Ids: pass id when your own code needs the control's id. The hint, error and limit ids are that id plus -hint, -error and -limit, so keep every id you pass unique on the page. An empty id is treated as none.
- Your own control: spread every attribute except class onto a custom component that forwards them to its input, and style it yourself.
- Accent:
--form-field-accentcolours the two-pixel focus ring. Keep it 3:1 against the page. - Error:
--form-field-errorcolours the message, the edge, the rule and a count over its limit. The default isred-700(6.5:1 on white). On a dark fill use a lighter red such as#f87171. - Neutrals:
--form-field-inkis the label and value,--form-field-mutedthe hint, marker and count,--form-field-borderthe control's edge (keep it 3:1),--form-field-hairlinethe disabled edge,--form-field-surfacethe control fill. - Worked retone for a
#09090bband:--form-field-accent:#fafafa;--form-field-ink:#fafafa;--form-field-muted:#a1a1aa;--form-field-border: rgb(255 255 255 / 0.4);--form-field-hairline: rgb(255 255 255 / 0.1);--form-field-surface:#18181b;--form-field-error:#f87171;--form-field-color-scheme: dark. - Density: size='compact' for settings panels and tables; the default for sign-up, booking and checkout forms. Space fields 24 px apart (
gap-6) so each hint groups with its own control.
Public CSS variables
| Variable | Token |
|---|---|
--form-field-accent | accent |
--form-field-ink | ink |
--form-field-muted | muted |
--form-field-hairline | hairline |
--form-field-border | border |
--form-field-surface | surface |
--form-field-error | error |
--form-field-color-scheme | colorScheme |
Accessibility#
On this page- The label is a native <label for> the control's id, so clicking it focuses the control and it is the control's accessible name, marker included. With
labelHiddenit is visually hidden, not removed. aria-describedbylists the hint, then the error, then the count's limit sentence, and only those that render; with none, the attribute is absent.aria-invalid='true' is set only while error is present; it is never set to 'false'.- The error is read with the control through
aria-describedbyand is not a live region, so it is not announced twice. Announce errors on submit with an error summary that links to each field, and move focus there yourself. - The error carries a visually hidden prefix ('Error:', translate it with
errorPrefix), a warning mark, a two-pixel control edge and a rule beside the field, so it never depends on colour. - The limit sentence is read once with the control, the current count is ordinary text under it, and a polite status repeats the count one second after the value stops changing (cleared first, so the same sentence can be announced again). Nothing is announced on load.
- Disabled and read-only are set on the control by you; the field styles them. Explain in the hint why a disabled value can't change.
- Focus shows a two-pixel ring in the accent, two pixels outside the control's radius. Controls are at least 44 px tall on coarse pointers, and their text is 16 px below the sm breakpoint so iOS does not zoom. In forced-colours mode the edge becomes a real system-colour border.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.