Skip to content
Download ZIP

Blue accent palette · 8.2 KB ZIP File receipt View as Markdown View code

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.
cmp_text_field_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_text_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-30df0c77ffa2e0c4797685db6e3aa7dda11bdc720ed084f051e02de846db75d2
TextField.svelte Svelte · 12.9 KB Raw
<!--
	A single-line text field: a visible label with an optional Required or Optional marker, an
	optional hint, the input with optional prefix and suffix adornments, and a consumer-supplied
	error. The adornments sit inside the field's one border, so prefix, input and suffix read and
	focus as a single shape. It is a plain native <input name>, so it submits with a form before
	any script runs; it never validates on its own.
-->
<script lang="ts" module>
	export type TextFieldSize = 'sm' | 'md' | 'lg';
	export type TextFieldLabelPosition = 'top' | 'start';
	export type TextFieldRequiredIndicator = 'required' | 'optional' | 'none';
	export type TextFieldAdornmentStyle = 'inline' | 'attached';
	/** The text-like input types; number, date and file inputs are their own controls. */
	export type TextFieldType = 'text' | 'email' | 'tel' | 'url' | 'search' | 'password';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLInputAttributes } from 'svelte/elements';

	type Passthrough = Omit<
		HTMLInputAttributes,
		| 'type'
		| 'value'
		| 'id'
		| 'name'
		| 'size'
		| 'required'
		| 'autocomplete'
		| 'class'
		| 'children'
		| 'aria-invalid'
		| 'prefix'
		| 'suffix'
	>;

	interface Props extends Passthrough {
		/** Visible label. Always shown; the placeholder never stands in for it. */
		label: string;
		/** Bindable value. */
		value?: string;
		/** Form field name for native submission. */
		name?: string;
		/** Help text under the label, linked with aria-describedby. */
		hint?: string;
		/** Validation message from the consumer. Sets aria-invalid; the component never validates. */
		error?: string | null;
		/** Autofill token, such as 'name', 'organization' or 'email'. */
		autocomplete?: HTMLInputAttributes['autocomplete'];
		/** Native type. Text-like types only. */
		type?: TextFieldType;
		/** Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. */
		required?: boolean;
		/** Which marker follows the label. Defaults to 'required' for required fields, otherwise 'none'. */
		requiredIndicator?: TextFieldRequiredIndicator;
		/** Marker text for required fields, for other languages. */
		requiredLabel?: string;
		/** Marker text for optional fields, for other languages. */
		optionalLabel?: string;
		/** Read before the error by screen readers only, for other languages. */
		errorPrefix?: string;
		/** sm 32 px, md 36 px, lg 40 px tall, matching action buttons; 44 px on touch screens. */
		size?: TextFieldSize;
		/** 'start' puts the label and hint in a rail beside the input once the field is 32rem wide. */
		labelPosition?: TextFieldLabelPosition;
		/** Leading adornment inside the border: an icon, '@' or 'https://'. */
		prefix?: Snippet;
		/** Trailing adornment inside the border: a unit, a domain or a small button. */
		suffix?: Snippet;
		/** 'inline' sets adornments in the field's own fill; 'attached' gives fixed text its own tinted segment. */
		adornmentStyle?: TextFieldAdornmentStyle;
		/** Hold one line under the field for the error, so setting it moves nothing below. */
		reserveError?: boolean;
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		value = $bindable(),
		name,
		hint,
		error = null,
		autocomplete,
		type = 'text',
		required = false,
		requiredIndicator,
		requiredLabel = 'Required',
		optionalLabel = 'Optional',
		errorPrefix = 'Error:',
		size = 'md',
		labelPosition = 'top',
		prefix,
		suffix,
		adornmentStyle = 'inline',
		reserveError = false,
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	/* Hint and error ids come from the instance, so a supplied input id can never collide with them. */
	const hintId = `${uid}-hint`;
	const errorId = `${uid}-error`;

	const invalid = $derived(!!error);
	const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));
	/* Hint first, then the error, then anything the consumer attached (a character counter). */
	const describedBy = $derived(
		[
			...new Set(
				[
					hint && hintId,
					invalid && errorId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ') || undefined
	);

	let input: HTMLInputElement | undefined = $state();

	/*
	 * A press on an adornment or the border focuses the input, as a press inside one box should.
	 * Presses on a button or link inside an adornment are left alone.
	 */
	function focusInput(event: PointerEvent) {
		if (!input || event.button !== 0 || event.target === input) return;
		const control = event.currentTarget as HTMLElement;
		const hit = (event.target as Element).closest('button, a, input, select, textarea, [tabindex]');
		if (hit && control.contains(hit)) return;
		if (input.matches(':disabled')) return;
		event.preventDefault();
		input.focus();
	}
</script>

<div
	class={['text-field @container min-w-0', className]}
	data-size={size}
	data-adornment={adornmentStyle}
	data-invalid={invalid ? '' : undefined}
>
	<div
		class={[
			'grid gap-2',
			labelPosition === 'start' && '@lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @lg:gap-x-6'
		]}
	>
		<div class={['min-w-0', labelPosition === 'start' && '@lg:pt-(--_rail-offset)']}>
			<label
				for={inputId}
				class={[
					'block leading-5 font-medium text-pretty break-words text-[var(--_ink)]',
					size === 'sm' ? 'text-[13px]' : 'text-sm'
				]}
			>
				{label}{#if marker === 'required'}
					<!-- The native required attribute already says so to assistive technology. The en
					     space is a break point, so a marker that wraps starts on the label's edge. -->
					<span class="text-[13px] font-normal text-[var(--_muted)]" aria-hidden="true"
						>&ensp;{requiredLabel}</span
					>{:else if marker === 'optional'}
					<span class="text-[13px] font-normal text-[var(--_muted)]">&ensp;{optionalLabel}</span
					>{/if}
			</label>
			{#if hint}
				<p
					id={hintId}
					class="mt-1 text-[13px] leading-5 text-pretty break-words text-[var(--_muted)]"
				>
					{hint}
				</p>
			{/if}
		</div>

		<div class="min-w-0">
			<!-- svelte-ignore a11y_no_static_element_interactions -->
			<div
				class={[
					'text-field__control flex min-h-(--_h) items-stretch border',
					'border-[var(--_edge)] bg-[var(--_surface)] text-[var(--_ink)]',
					'transition-[border-color,box-shadow,background-color] duration-150 ease-[cubic-bezier(.2,0,0,1)]',
					size === 'sm' ? 'rounded-md' : 'rounded-lg'
				]}
				onpointerdown={focusInput}
			>
				{#if prefix}
					<span
						class="text-field__adornment text-field__adornment--start flex shrink-0 items-center gap-2 ps-3 whitespace-nowrap text-[var(--_muted)]"
					>
						{@render prefix()}
					</span>
				{/if}
				<input
					bind:this={input}
					{...rest}
					bind:value
					{type}
					{name}
					{autocomplete}
					{required}
					id={inputId}
					aria-describedby={describedBy}
					aria-invalid={invalid ? 'true' : undefined}
					class={[
						'text-field__input min-w-20 flex-1 bg-transparent text-[var(--_ink)] outline-none',
						'placeholder:text-[var(--_placeholder)] disabled:cursor-not-allowed',
						'pointer-coarse:text-base',
						size === 'sm' && 'text-[13px] leading-5',
						size === 'md' && 'text-sm leading-5',
						size === 'lg' && 'text-base leading-6',
						/* Inline text adornments run straight into the value (halcyon.app/northwind); an
						   attached segment has its own edge, so the value gets the full padding back. */
						prefix && adornmentStyle === 'inline' ? 'ps-1' : 'ps-3',
						suffix && adornmentStyle === 'inline' ? 'pe-2' : 'pe-3'
					]}
				/>
				{#if suffix}
					<span
						class="text-field__adornment text-field__adornment--end flex shrink-0 items-center gap-2 pe-3 whitespace-nowrap text-[var(--_muted)]"
					>
						{@render suffix()}
					</span>
				{/if}
			</div>

			{#if invalid}
				<p
					id={errorId}
					class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-[var(--_danger)]"
				>
					<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
						<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
							<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
							<path
								d="M8 4.75v3.5"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
							/>
							<circle cx="8" cy="11" r="1" fill="currentColor" />
						</svg>
					</span>
					<span class="min-w-0 text-pretty break-words"
						><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
					>
				</p>
			{:else if reserveError}
				<div class="mt-1 h-5" aria-hidden="true"></div>
			{/if}
		</div>
	</div>
</div>

<style>
	/* Public tokens: set --text-field-* on the field or any ancestor to retone it. */
	.text-field {
		--_accent: var(--text-field-accent, #1d4ed8);
		--_danger: var(--text-field-danger, #b91c1c);
		--_ink: var(--text-field-ink, #18181b);
		--_muted: var(--text-field-muted, #52525b);
		--_placeholder: var(--text-field-placeholder, #71717a);
		/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
		--_hairline: var(--text-field-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--text-field-surface, #ffffff);

		/* The field's height per size; the rail offset centres the label's first line on it. */
		--_h: 2.25rem;
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
		--_rail-offset: calc((var(--_h) - 1.25rem) / 2);
	}
	.text-field[data-size='sm'] {
		--_h: 2rem;
	}
	.text-field[data-size='lg'] {
		--_h: 2.5rem;
	}
	@media (pointer: coarse) {
		.text-field[data-size] {
			--_h: 2.75rem;
		}
	}

	/* Invalid: the edge and the focus ring both carry the error colour. */
	.text-field[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
	}

	/* Hover only where typing is possible: one step darker edge. */
	.text-field__control:hover:not(:has(input:disabled, input[readonly])) {
		--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
	}
	.text-field[data-invalid] .text-field__control:hover {
		--_edge: var(--_danger);
	}

	/* Focus: a two-pixel edge in the accent (one border plus one ring), with a soft halo outside. */
	.text-field__control:has(input:focus-visible) {
		border-color: var(--_ring);
		box-shadow:
			0 0 0 1px var(--_ring),
			0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
	}

	/* Read-only: a raised fill, full-strength text, still focusable and selectable. */
	.text-field__control:has(input[readonly]) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}

	/* Disabled: raised fill, faded, and not interactive. */
	.text-field__control:has(input:disabled) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
		opacity: 0.55;
		cursor: not-allowed;
	}

	/*
	 * Autofill: the browser paints its own fill on the input alone. Keep the input transparent and
	 * ink-coloured, and tint the whole shape instead, so the adornments share the autofilled look.
	 */
	.text-field__input:autofill {
		-webkit-text-fill-color: var(--_ink);
		transition: background-color 0s 86400s;
	}
	.text-field__control:has(input:autofill) {
		background-color: color-mix(in oklab, var(--_surface), var(--_accent) 6%);
	}

	/* An icon in an inline prefix needs the gap that text runs through. */
	.text-field__adornment--start:has(:global(svg)) {
		padding-inline-end: 0.25rem;
	}

	/*
	 * Attached adornments: fixed text (.com, kg, https://) in its own raised segment, one divider
	 * from the value, inside the same border and focus edge.
	 */
	.text-field[data-adornment='attached'] .text-field__control {
		overflow: hidden;
	}
	.text-field[data-adornment='attached'] .text-field__adornment {
		align-self: stretch;
		padding-inline: 0.75rem;
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}
	.text-field[data-adornment='attached'] .text-field__adornment--start {
		border-inline-end: 1px solid color-mix(in oklab, var(--_hairline) 50%, transparent);
	}
	.text-field[data-adornment='attached'] .text-field__adornment--end {
		border-inline-start: 1px solid color-mix(in oklab, var(--_hairline) 50%, transparent);
	}

	/* Adornments share the input's type and line box, so their text sits on the value's baseline. */
	.text-field__adornment :global(svg) {
		width: 1rem;
		height: 1rem;
		flex: none;
	}
	.text-field[data-size='sm'] .text-field__adornment {
		font-size: 13px;
		line-height: 1.25rem;
	}
	.text-field[data-size='md'] .text-field__adornment {
		font-size: 14px;
		line-height: 1.25rem;
	}
	.text-field[data-size='lg'] .text-field__adornment {
		font-size: 16px;
		line-height: 1.5rem;
	}
	@media (pointer: coarse) {
		.text-field[data-size] .text-field__adornment {
			font-size: 16px;
		}
	}

	/* Forced colours drop shadows and repaint borders, so focus becomes a system-colour outline. */
	@media (forced-colors: active) {
		.text-field__control:has(input:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>

Give it a label and a name and it renders a labelled native input that submits with its form before any script runs. Bind value, pass a hint, and set error when your own validation fails; the field sets aria-invalid and shows the message with an icon. It does not validate, sanitise or submit anything, and it holds no form state.

Suggested location
src/lib/components/text-field-01
Required props
label

Limitations

  • The component never validates. The native required and pattern attributes still trigger the browser's own validation on submit unless the form sets novalidate.
  • The error appears below the input when you set it and the field grows to fit it. Set reserveError to hold one line for it; a longer error still grows the field.
  • Adornments do not shrink; keep them short (a symbol, a domain, a unit or a small button). The input keeps at least 80 px.
  • Adornments are not part of the label or description. If a prefix or suffix carries meaning a screen reader user needs, say it in the label or hint as well.
  • type accepts text-like inputs only (text, email, tel, url, search, password); numbers, dates and files are separate controls.
  • Pressing an adornment focuses the input once the page has hydrated; before that only the label and the input itself do.

Example

Svelte
<script lang="ts">
	import TextField from '$lib/components/text-field-01/TextField.svelte';

	let company = $state('');
	let error = $state<string | null>(null);

	function check(event: SubmitEvent) {
		error = company.trim() ? null : 'Enter your company name, or switch to a personal workspace.';
		if (error) event.preventDefault();
	}
</script>

<form method="POST" class="max-w-sm" onsubmit={check}>
	<TextField
		label="Company name"
		name="organization"
		autocomplete="organization"
		required
		hint="Printed on invoices and the guest sign-in page."
		bind:value={company}
		{error}
	/>
</form>

Text field#

The base single-line field. Every other text field (email, password, URL, a field with a character counter) copies its label, hint and error structure.

Svelte
<TextField
	label="Company name"
	name="organization"
	autocomplete="organization"
	required
	hint="Printed on invoices and the guest sign-in page."
	bind:value={company}
	{error}
/>

Validation is yours#

The field never validates. Set error when your own check or your server rejects the value, and clear it (null) when it passes. While it is set the field shows the message under the input with an icon, turns its edge and focus ring to the danger colour, sets aria-invalid and adds the error to aria-describedby after the hint. Keep the value the person typed; never clear a field because it failed.

The error is not a live region. When a submit fails, move focus to the first invalid field or to an error summary above the form.

Required and optional#

required sets the native attribute and, by default, a muted Required marker after the label. When most of a form is required, mark only the exceptions:

Svelte
<TextField label="Full name" name="name" required requiredIndicator="none" />
<TextField label="Job title" name="job-title" requiredIndicator="optional" />

Prefix and suffix#

Both are snippets rendered inside the field's border, in the muted tone, so the adornment and the input read as one shape and the focus edge wraps both. A press on an adornment focuses the input unless it lands on a button or link.

Svelte
{#snippet at()}@{/snippet}
{#snippet copy()}<button type="button" onclick={copyLink}>Copy</button>{/snippet}

<TextField label="Username" name="username" prefix={at} />
<TextField label="Invite link" value={link} readonly suffix={copy} />

Keep adornments short. They are not part of the accessible name, so if one carries meaning (".com is added for you"), say so in the label or hint.

Label beside the field#

labelPosition="start" puts the label and hint in a rail at one third of the field's width once the field itself is 32rem wide (a container query, so it works in any column), with the label level with the input's text. Narrower than that it stacks like the default.

Native attributes#

Anything else you pass (inputmode, pattern, maxlength, placeholder, readonly, disabled, spellcheck, event handlers) goes straight to the <input>.

Retoning#

On a #09090b band:

CSS
.band {
	background: #09090b;
	color-scheme: dark;
	--text-field-accent: #fafafa;
	--text-field-ink: #fafafa;
	--text-field-muted: #a1a1aa;
	--text-field-placeholder: #a1a1aa;
	--text-field-hairline: rgb(255 255 255 / 0.35);
	--text-field-surface: #18181b;
	--text-field-danger: #f87171;
}

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label, always shown. The placeholder never stands in for it.
valuestringNoNoneBindable value. Undefined renders an empty field.
namestringNoNoneForm field name, so native submission includes the field.
hintstringNoNoneHelp text under the label, linked first in aria-describedby.
errorstring | nullNonullValidation message from your code. When set, the field shows it with an icon, turns its edge and focus ring to the danger colour and sets aria-invalid.
autocompleteHTMLInputAttributes['autocomplete']NoNoneAutofill token, for example 'name', 'organization', 'email' or 'tel'.
type'text' | 'email' | 'tel' | 'url' | 'search' | 'password'No'text'Native input type, text-like types only.
requiredbooleanNofalseNative required attribute. Shows the Required marker unless requiredIndicator says otherwise.
requiredIndicator'required' | 'optional' | 'none'NoNoneWhich marker follows the label. Defaults to 'required' for required fields and 'none' otherwise. When most fields are required, mark only the optional ones.
requiredLabelstringNo'Required'Marker text for required fields.
optionalLabelstringNo'Optional'Marker text for optional fields.
errorPrefixstringNo'Error:'Visually hidden text read before the error.
size'sm' | 'md' | 'lg'No'md'32, 36 or 40 px tall with 13, 14 or 16 px text, matching buttons of the same size. Every size is 44 px with 16 px text on touch screens.
labelPosition'top' | 'start'No'top''start' moves the label and hint into a rail beside the input once the field is 32rem wide, with the label level with the input's text. Narrower, it stacks.
prefixSnippetNoNoneLeading adornment inside the border: an icon, '@' or 'halcyon.app/'. See adornmentStyle.
suffixSnippetNoNoneTrailing adornment inside the border: a unit, a domain or a small button. See adornmentStyle.
adornmentStyle'inline' | 'attached'No'inline''inline' sets adornments in the field's own fill, running straight into the value (@mira, halcyon.app/northwind). 'attached' gives fixed text its own tinted segment behind a divider, for endings like .com or a unit.
reserveErrorbooleanNofalseHold one line under the field for the error, so setting a one-line error moves nothing below. Longer errors still grow the field.
idstringNoNoneInput id. Defaults to one generated per instance. The hint and error always take generated ids, so a supplied id never collides with them; keep it unique on the page.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

Change the copy and markers through props and retone the field through seven --text-field-* CSS variables. Hover and the read-only fill are mixed from those, so they follow any retone.

  • Accent: --text-field-accent colours the focus edge and its halo, and tints the autofilled fill. Keep it at 3:1 against the page.
  • Danger: --text-field-danger colours the error edge, the error focus ring, the icon and the message. The default is red-700 (6.5:1 on white).
  • Neutrals: --text-field-ink is the label and value, --text-field-muted the hint, marker and adornments, --text-field-placeholder the placeholder, --text-field-hairline the resting edge (keep it at 3:1 against the page and the fill, since it is the only thing marking an empty field) and --text-field-surface the fill.
  • Worked retone for a #09090b band: --text-field-accent: #fafafa; --text-field-ink: #fafafa; --text-field-muted: #a1a1aa; --text-field-placeholder: #a1a1aa; --text-field-hairline: rgb(255 255 255 / 0.35) (3:1 on the fill); --text-field-surface: #18181b; --text-field-danger: #f87171. Add color-scheme: dark on the band.
  • Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set --text-field-surface: #fffdf9.
  • Sizes: sm for dense tables and filters, md for app forms, lg for marketing and checkout forms. Match the size of any button in the same row.
  • Adornments: inline for text that joins the value (@, a path such as halcyon.app/, an icon); attached for a fixed ending the person should read as separate (.com, kg, per month). A small button in a suffix works with either.
  • Layout: the field fills its container. Stack fields with a 20 to 24 px gap; use labelPosition 'start' for settings rows, and pass class for placement.
  • Markers: when most fields are required, set requiredIndicator 'none' on them and 'optional' on the rest. Translate requiredLabel, optionalLabel and errorPrefix with the page.

Public CSS variables

VariableToken
--text-field-accentaccent
--text-field-dangerdanger
--text-field-inkink
--text-field-mutedmuted
--text-field-placeholderplaceholder
--text-field-hairlinehairline
--text-field-surfacesurface

Accessibility#

On this page
  • A native <label for> names the input. Its id comes from $props.id() or your id prop; the hint and error ids are always generated per instance, so they never collide with a supplied id.
  • aria-describedby lists the hint id, then the error id, then any aria-describedby you pass (a character counter, for example), each only when present. aria-invalid is set only while error is set.
  • The Required marker is visible text beside the label and hidden from assistive technology, because the native required attribute already announces it. The Optional marker is part of the label's name.
  • The error is never colour alone: it has an icon, text, and a visually hidden errorPrefix. It is not a live region; move focus or announce a summary when a submit fails, as your form decides.
  • Focus draws a two-pixel edge in the accent (the danger colour while invalid) around the whole shape, adornments included, plus a soft halo; in forced-colours mode it becomes a two-pixel Highlight outline. Keep the accent at 3:1 against the page.
  • Read-only fields stay in the tab order and submit their value; disabled fields leave the tab order and do not submit. Explain a disabled field in its hint.
  • Inputs use 16 px text and grow to 44 px on touch screens, so iOS does not zoom and targets clear 44 px.

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.