# 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.

- ID: `cmp_form_field_01`
- Slug: `form-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `form-structure`
- Detail page: https://pagesugar.com/components/form-field-01?variant=neutral
- Preview: https://pagesugar.com/preview/form-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4` |
| `blue` | Blue accent | no | `sha256-d61d686e3f410c5c80c4d76ea7678417633fba023b4cf4bc38278a961c354670` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/form-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass 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).

Required props: `label`, `control`

```svelte
<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>
```

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.class onto 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.

## Usage guide

### 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:

```svelte
<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:

```svelte
<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`:

```svelte
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:

```css
.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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label text: the question or the name of the value. Always rendered as a \<label for\> the control. |
| `control` | `Snippet<[FormFieldControlAttributes]>` | yes |  | Renders the control. Spread the attributes onto it: { id, 'aria-describedby', 'aria-invalid', required, class }. |
| `hint` | string \| Snippet | no |  | Guidance under the label: a format or a constraint, not the label again. A snippet when it holds a link. |
| `error` | `string` | no |  | 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 |  | Marker after the label of a field that is not required, such as '(optional)'. Omitted, no marker renders. |
| `id` | `string` | no |  | 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 |  | 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 |  | { value, max }: characters typed and the limit. Renders the count under the control and adds the limit to aria-describedby. |
| `countThreshold` | `number` | no |  | 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 |  | The count's wording: limit(max), remaining(n) and over(n), each returning a sentence. Defaults to English. |
| `class` | `string` | no |  | Extra classes for the field's root, such as a width or grid placement. |

## Customization

Change 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 and optionalLabel='(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-accent colours the two-pixel focus ring. Keep it 3:1 against the page.
- Error: --form-field-error colours the message, the edge, the rule and a count over its limit. The default is red-700 (6.5:1 on white). On a dark fill use a lighter red such as #f87171.
- Neutrals: --form-field-ink is the label and value, --form-field-muted the hint, marker and count, --form-field-border the control's edge (keep it 3:1), --form-field-hairline the disabled edge, --form-field-surface the control fill.
- Worked retone for a #09090b band: --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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--form-field-accent` |
| `ink` | `--form-field-ink` |
| `muted` | `--form-field-muted` |
| `hairline` | `--form-field-hairline` |
| `border` | `--form-field-border` |
| `surface` | `--form-field-surface` |
| `error` | `--form-field-error` |
| `colorScheme` | `--form-field-color-scheme` |

## Accessibility

- 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 labelHidden it is visually hidden, not removed.
- aria-describedby lists 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-describedby and 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `FormField.svelte`
- Suggested directory: `src/lib/components/form-field-01`
- Files: 6
- Artifact digest: `sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `FormField.svelte`

Role: entry · 9476 bytes · SHA-256 `2b3194f1b610a297a056158b74a29c4665a378ec925182da15783be5aa487fc5`

```svelte
<!--
	One form field in the GOV.UK order: label, hint, error, control, then an optional character
	count. It owns the ids and hands the control its id, aria-describedby (hint, then error, then the
	limit), aria-invalid (only while there is an error), required and the field's control styling,
	all through the `control` snippet. An error hangs a rule in the start gutter beside the whole
	field and adds a thicker edge, a mark and the words, so it never rests on colour alone and
	nothing inside the field moves sideways. It renders on the server and keeps no state of its
	own; only the count's announcement needs the browser.
-->
<script lang="ts">
	import type { Snippet } from 'svelte';
	import FieldCount from './parts/FieldCount.svelte';
	import FieldError from './parts/FieldError.svelte';
	import FieldHint from './parts/FieldHint.svelte';
	import FieldLabel from './parts/FieldLabel.svelte';
	import type {
		FormFieldControlAttributes,
		FormFieldCount,
		FormFieldCountMessages,
		FormFieldSize
	} from './types';

	interface Props {
		/** Visible label text: the question or the name of the value. */
		label: string;
		/** Guidance under the label. A snippet when it holds a link. */
		hint?: string | Snippet;
		/** The current validation message. Set, the field is invalid. */
		error?: string;
		/** Passes required to the control and shows requiredLabel. */
		required?: boolean;
		/** Marker shown after the label of a required field. Empty for none. */
		requiredLabel?: string;
		/** Marker shown after the label of a field that is not required, such as "(optional)". */
		optionalLabel?: string;
		/** The control's id. Generated per instance when omitted. */
		id?: string;
		/** Renders the control. Spread the attributes onto it. */
		control: Snippet<[FormFieldControlAttributes]>;
		/** Hide the label visually; it stays the control's accessible name. */
		labelHidden?: boolean;
		/** Wrap the label in an <h1> or <h2>, for a page that asks one question. */
		labelHeading?: 1 | 2;
		/** 'compact' sets 36 px controls and smaller text, for settings and dense panels. */
		size?: FormFieldSize;
		/** Read before the error by assistive technology. */
		errorPrefix?: string;
		/** Show the warning mark beside the error. */
		errorIcon?: boolean;
		/** Characters typed and the limit, for a count under the control. */
		count?: FormFieldCount;
		/** Show the count only once this many characters or fewer remain. */
		countThreshold?: number;
		/** The count's wording, for translation. */
		countMessages?: Partial<FormFieldCountMessages>;
		/** Extra classes for the field, such as a width. */
		class?: string;
	}

	let {
		label,
		hint,
		error,
		required = false,
		requiredLabel = '(required)',
		optionalLabel,
		id,
		control,
		labelHidden = false,
		labelHeading,
		size = 'default',
		errorPrefix = 'Error:',
		errorIcon = true,
		count,
		countThreshold,
		countMessages,
		class: className
	}: Props = $props();

	const uid = $props.id();

	const controlId = $derived(id || `${uid}-control`);
	const hintId = $derived(`${controlId}-hint`);
	const errorId = $derived(`${controlId}-error`);
	const limitId = $derived(`${controlId}-limit`);

	const hasHint = $derived(typeof hint === 'function' || Boolean(hint));
	const marker = $derived((required ? requiredLabel : optionalLabel) || undefined);
	/* With nothing visible above the control, the label leaves the flow so no gap is left behind. */
	const textBlock = $derived(!labelHidden || hasHint || Boolean(error));

	const plural = (n: number) => (n === 1 ? 'character' : 'characters');
	const messages = $derived<FormFieldCountMessages>({
		limit: countMessages?.limit ?? ((max) => `You can enter up to ${max} ${plural(max)}`),
		remaining: countMessages?.remaining ?? ((n) => `You have ${n} ${plural(n)} remaining`),
		over: countMessages?.over ?? ((n) => `You have ${n} ${plural(n)} too many`)
	});

	/*
	 * The control's styling travels with its attributes, so a plain <input {...attributes}> is
	 * finished. 16 px text on phones keeps iOS from zooming; 14 px from sm up. The edge is a real
	 * one-pixel border, so a textarea's scrollbar sits inside it; the error adds a second pixel as
	 * an inset shadow (see the style block), so the control's size never changes.
	 */
	const controlClass = $derived(
		[
			'form-field-control block w-full min-w-0 rounded-lg bg-[var(--_surface)] px-3 py-2 text-[var(--_ink)] placeholder:text-[var(--_placeholder)]',
			'outline-offset-2 outline-[var(--_accent)] focus-visible:outline-2 transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(.2,0,0,1)] pointer-coarse:min-h-11',
			'disabled:cursor-not-allowed disabled:bg-[var(--_raised)] disabled:text-[var(--_muted)] [&[readonly]]:bg-[var(--_raised)] [&:is(textarea)]:resize-y',
			/* 22 px lines, 8 px padding and the border make 40 px; compact's 18 px lines make 36 px. */
			size === 'compact'
				? 'text-base/[22px] sm:text-sm/[18px]'
				: 'text-base/[22px] sm:text-sm/[22px]'
		].join(' ')
	);

	const attributes = $derived<FormFieldControlAttributes>({
		id: controlId,
		'aria-describedby':
			[hasHint && hintId, error && errorId, count && limitId].filter(Boolean).join(' ') ||
			undefined,
		'aria-invalid': error ? 'true' : undefined,
		required,
		class: controlClass
	});
</script>

<div
	class={[
		'form-field relative grid min-w-0',
		labelHeading && !labelHidden ? 'gap-4' : 'gap-2',
		className
	]}
	data-invalid={error ? '' : undefined}
>
	{#if error}
		<!-- The error rule hangs in the gutter, so the field's own left edge never moves. -->
		<span
			class="pointer-events-none absolute inset-y-0 -start-3 w-0.5 rounded-full bg-[var(--_error)]"
			aria-hidden="true"
		></span>
	{/if}
	{#if textBlock}
		<div class={['grid min-w-0', labelHeading && !labelHidden ? 'gap-2' : 'gap-1']}>
			{@render fieldLabel()}
			{#if hint}
				<FieldHint id={hintId} {hint} {size} />
			{/if}
			<FieldError id={errorId} message={error} prefix={errorPrefix} showIcon={errorIcon} {size} />
		</div>
	{:else}
		{@render fieldLabel()}
	{/if}
	{@render control(attributes)}
	{#if count}
		<FieldCount {limitId} {count} threshold={countThreshold} {messages} {size} />
	{/if}
</div>

{#snippet fieldLabel()}
	<FieldLabel
		for={controlId}
		text={label}
		{marker}
		hidden={labelHidden}
		heading={labelHeading}
		{size}
	/>
{/snippet}

<style>
	/* Public tokens: set --form-field-* on the field or any ancestor to retone it. */
	.form-field {
		--_accent: var(--form-field-accent, #18181b);
		--_ink: var(--form-field-ink, #18181b);
		--_muted: var(--form-field-muted, #52525b);
		--_hairline: var(--form-field-hairline, rgb(0 0 0 / 0.1));
		/* A control boundary, not a hairline: 3:1 against white. */
		--_border: var(--form-field-border, rgb(0 0 0 / 0.44));
		--_surface: var(--form-field-surface, #ffffff);
		--_error: var(--form-field-error, #b91c1c);
		--_color-scheme: var(--form-field-color-scheme, light);

		--_border-hover: color-mix(in oklab, var(--_border), var(--_ink) 40%);
		--_placeholder: color-mix(in oklab, var(--_muted) 82%, var(--_surface));
		--_raised: color-mix(in oklab, var(--_ink) 4%, var(--_surface));
	}

	.form-field :global(.form-field-control) {
		--_edge: var(--_border);
		--_inset: 0 0 #0000;
		border: 1px solid var(--_edge);
		box-shadow: var(--_inset);
		color-scheme: var(--_color-scheme);
	}

	.form-field :global(.form-field-control:hover:not(:disabled, [readonly], [aria-invalid='true'])) {
		--_edge: var(--_border-hover);
	}

	.form-field :global(.form-field-control:disabled),
	.form-field :global(.form-field-control[readonly]) {
		--_edge: var(--_hairline);
	}

	/* Invalid: a two-pixel edge in the error colour, the second pixel inset so the size holds.
	   Last, so it outranks the disabled and read-only edges. */
	.form-field :global(.form-field-control[aria-invalid='true']) {
		--_edge: var(--_error);
		--_inset: inset 0 0 0 1px var(--_error);
	}

	/* Autofill keeps the field's own fill and ink, tinted one step toward the accent. */
	.form-field :global(.form-field-control:-webkit-autofill) {
		-webkit-text-fill-color: var(--_ink);
		box-shadow:
			var(--_inset),
			inset 0 0 0 100vmax color-mix(in oklab, var(--_accent) 6%, var(--_surface));
	}

	/*
	 * A drawn chevron in place of the platform's: two strokes painted with gradients, so it takes
	 * the muted token, at the end of the row in either direction.
	 */
	.form-field :global(select.form-field-control:not([multiple], [size])) {
		--_stroke:
			transparent calc(50% - 0.8px), var(--_muted) calc(50% - 0.8px),
			var(--_muted) calc(50% + 0.8px), transparent calc(50% + 0.8px);
		appearance: none;
		padding-inline-end: 2.25rem;
		background-image:
			linear-gradient(45deg, var(--_stroke)), linear-gradient(-45deg, var(--_stroke));
		background-repeat: no-repeat;
		background-size: 5px 5px;
		background-position:
			right 1.25rem center,
			right calc(1.25rem - 4.5px) center;
	}

	.form-field :global(select.form-field-control:not([multiple], [size]):dir(rtl)) {
		background-image:
			linear-gradient(45deg, var(--_stroke)), linear-gradient(-45deg, var(--_stroke));
		background-position:
			left calc(1.25rem - 4.5px) center,
			left 1.25rem center;
	}

	/* Forced colours keep the border but drop the inset pixel, so the invalid edge stays thick. */
	@media (forced-colors: active) {
		.form-field :global(.form-field-control[aria-invalid='true']:not(:focus-visible)) {
			outline: 1px solid CanvasText;
			outline-offset: -2px;
		}
	}
</style>
```

#### `parts/FieldCount.svelte`

Role: component · 2260 bytes · SHA-256 `34c27ab19eaa581dca9adc00aae04f0dbaaf9e3581b59bae38333e200fccc908`

```svelte
<!--
	The character count under a textarea, after GOV.UK's: the limit is read once with the control,
	the visible count updates as the value changes, and a polite status repeats it a second after
	typing stops rather than on every key. It counts; it never trims the value or blocks typing.
-->
<script lang="ts">
	import type { FormFieldCount, FormFieldCountMessages, FormFieldSize } from '../types';

	interface Props {
		/** Id of the limit sentence, which the control lists in aria-describedby. */
		limitId: string;
		count: FormFieldCount;
		/** Show the count only once this many characters or fewer remain. */
		threshold?: number;
		messages: FormFieldCountMessages;
		size?: FormFieldSize;
	}

	let { limitId, count, threshold, messages, size = 'default' }: Props = $props();

	/* Whole, non-negative character counts, so a stray fraction or NaN never reaches the words. */
	const whole = (n: number) => (Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0);
	const max = $derived(whole(count.max));
	const remaining = $derived(max - whole(count.value));
	const over = $derived(remaining < 0);
	/* Below the threshold the line keeps its height, so the page does not move when it appears. */
	const shown = $derived(over || threshold === undefined || remaining <= threshold);
	const text = $derived(over ? messages.over(-remaining) : messages.remaining(remaining));

	/*
	 * Announce only changes made after the page is live, a second after the last one, so a screen
	 * reader hears where typing stopped instead of every keystroke.
	 */
	let announcement = $state('');
	let primed = false;
	$effect(() => {
		const next = shown ? text : '';
		if (!primed) {
			primed = true;
			return;
		}
		// Clear first, so the same sentence announced twice is still a change.
		announcement = '';
		const timer = setTimeout(() => (announcement = next), 1000);
		return () => clearTimeout(timer);
	});
</script>

<p
	class={[
		'text-start tabular-nums',
		size === 'compact' ? 'text-xs/4' : 'text-[13px]/5',
		over ? 'font-medium text-[var(--_error)]' : 'text-[var(--_muted)]',
		!shown && 'invisible'
	]}
>
	{text}
</p>
<span id={limitId} class="sr-only">{messages.limit(max)}</span>
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
```

#### `parts/FieldError.svelte`

Role: component · 1725 bytes · SHA-256 `f09505d338d4c75df8b67b67867448f545ea4689e52037f08bd30e27201307bd`

```svelte
<!--
	One specific validation message, between the hint and the control (the GOV.UK order), so a
	phone keyboard or a magnified screen never hides it under the control. A visually hidden
	prefix ("Error:") and the mark carry the meaning without colour. It is not a live region: it is
	read with the control through aria-describedby, and an error summary announces it on submit.
-->
<script lang="ts">
	import type { FormFieldSize } from '../types';

	interface Props {
		id: string;
		/** The message. Empty or omitted, nothing renders. */
		message?: string;
		/** Read before the message by assistive technology. Translate it with the page. */
		prefix?: string;
		/** Show the warning mark beside the message. */
		showIcon?: boolean;
		size?: FormFieldSize;
	}

	let { id, message, prefix = 'Error:', showIcon = true, size = 'default' }: Props = $props();
</script>

{#if message}
	<p
		{id}
		class={[
			'flex max-w-[34em] items-start gap-2 text-start font-medium text-[var(--_error)]',
			size === 'compact' ? 'text-xs/4' : 'text-[13px]/5'
		]}
	>
		{#if showIcon}
			<!-- One line tall, so the mark stays level with the first line of a wrapped message. -->
			<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
				<svg class={size === 'compact' ? 'size-3.5' : 'size-4'} viewBox="0 0 16 16" fill="none">
					<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5" />
					<path d="M8 4.75v3.75" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
					<circle cx="8" cy="11.1" r="1" fill="currentColor" />
				</svg>
			</span>
		{/if}
		<span class="min-w-0 text-pretty break-words"
			><span class="sr-only">{`${prefix} `}</span>{message}</span
		>
	</p>
{/if}
```

#### `parts/FieldHint.svelte`

Role: component · 715 bytes · SHA-256 `3605fef3f6d6e307e1de68bb77b653c37a822f8ff4e467c1caf25e3780066fc5`

```svelte
<!--
	Guidance under the label: the format or a constraint, never a restatement of the label.
	Its id goes into the control's aria-describedby before the error's.
-->
<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { FormFieldSize } from '../types';

	interface Props {
		id: string;
		/** Plain text, or a snippet when the hint holds a link. */
		hint: string | Snippet;
		size?: FormFieldSize;
	}

	let { id, hint, size = 'default' }: Props = $props();
</script>

<p
	{id}
	class={[
		'max-w-[34em] text-start text-pretty break-words text-[var(--_muted)]',
		size === 'compact' ? 'text-xs/4' : 'text-[13px]/5'
	]}
>
	{#if typeof hint === 'function'}{@render hint()}{:else}{hint}{/if}
</p>
```

#### `parts/FieldLabel.svelte`

Role: component · 1745 bytes · SHA-256 `8f113825afbc4e5a0acea84146a9ce1c617782ee07ba55942673eadf8a43d27d`

```svelte
<!--
	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>
```

#### `types.ts`

Role: types · 1301 bytes · SHA-256 `241bc9e2b63775882674ad6a6f3e8d03545eb012ca2d48568c03b63d272c7df6`

```ts
/** Attributes the field hands to its control snippet. Spread them onto the input, select or textarea. */
export interface FormFieldControlAttributes {
	/** The control's id; the label's `for` points at it. */
	id: string;
	/** Hint, then error, then the character limit, listing only the parts that render. */
	'aria-describedby': string | undefined;
	/** Present only while there is an error. */
	'aria-invalid': 'true' | undefined;
	/** Native required, from the field's `required` prop. */
	required: boolean;
	/** The field's control styling. Leave it off to style the control yourself. */
	class: string;
}

/** Characters typed so far and the limit, for the count under a textarea. */
export interface FormFieldCount {
	value: number;
	max: number;
}

/** The count's wording, so it can be translated. Each receives a whole number of characters. */
export interface FormFieldCountMessages {
	/** Read to assistive technology with the control, e.g. "You can enter up to 280 characters". */
	limit: (max: number) => string;
	/** Shown under the limit, e.g. "You have 68 characters remaining". */
	remaining: (remaining: number) => string;
	/** Shown over the limit, e.g. "You have 12 characters too many". */
	over: (over: number) => string;
}

export type FormFieldSize = 'default' | 'compact';
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4`
- Entry: `FormField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/bundle.zip (12364 bytes, sha256 `6e0a45b976b4f4fbff46a90af0a8e13beae5819a99b2aea420b3f2a1e5195e39`)

Files:

- `FormField.svelte` (entry, 9476 bytes): https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/source/FormField.svelte
- `parts/FieldCount.svelte` (component, 2260 bytes): https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/source/parts/FieldCount.svelte
- `parts/FieldError.svelte` (component, 1725 bytes): https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/source/parts/FieldError.svelte
- `parts/FieldHint.svelte` (component, 715 bytes): https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/source/parts/FieldHint.svelte
- `parts/FieldLabel.svelte` (component, 1745 bytes): https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/source/parts/FieldLabel.svelte
- `types.ts` (types, 1301 bytes): https://pagesugar.com/artifacts/cmp_form_field_01/1.0.0/neutral/sha256-faa76ded1719195b4c4357ab015975ee5b9046034f53a62c0fb36c4aacbb08c4/source/types.ts
