# URL field

> A web address field that adds https:// on blur and says so, or shows a fixed https:// prefix, with scheme and host rules. javascript: and data: are always refused. A native type=url input before hydration.

- ID: `cmp_url_field_01`
- Slug: `url-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T04:16:33Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/url-field-01?variant=blue
- Preview: https://pagesugar.com/preview/url-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-3a669ebf29520d9fe4dfd58e12e01ce78165ded5ee7a8f916e8cf45da4ca3a7b` |
| `blue` | Blue accent | no | `sha256-e6833bc749a0c7574a7535ef234cfb3a3b45431de63411231dc5b558db32d381` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/url-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Give it a label and a name and it renders a labelled web address field that submits with its form. On blur it trims the value, adds https:// when that is allowed and missing, and checks the scheme and host against your rules, showing a message and calling onInvalid when they fail. It never fetches the address, checks that it resolves, renders a preview or vouches for the destination; reachability and ownership checks belong on your server.

Required props: `label`

```svelte
<script lang="ts">
	import UrlField from '$lib/components/url-field-01/UrlField.svelte';

	let website = $state('');
</script>

<form method="POST" class="max-w-sm">
	<UrlField
		label="Website"
		name="website"
		placeholder="example.com"
		hint="Linked from your public profile."
		bind:value={website}
		onInvalid={(reason) => console.info('website rejected:', reason)}
	/>
</form>
```

Limitations:

- Before the page hydrates, the field is a plain type=url input: the browser's own validation applies, so a bare domain like example.com is refused until the script runs. Set novalidate on the form if your server normalises instead.
- prefix-adornment needs the script for its prefix: before hydration it renders as a plain named type=url field holding the full URL. Once hydrated it shows the fixed https://, the visible type=text input holds only the rest, and a hidden input (carrying your form attribute) submits the full URL under name.
- prefix-adornment always saves https://. A pasted http:// address is saved as https://; any other scheme is kept as typed and refused.
- The checks are about form, not safety: a well-formed https address can still lead anywhere. Check it again on your server.
- Hosts are compared after punycode conversion, so allowedHosts \['münchen.de'\] matches xn--mnchen-3ya.de. The saved value keeps what the person typed.
- One line under the field is reserved for the notice or an error. The default messages fit it at 360 px; a longer error from you or your server wraps and grows the field, so keep server messages short.
- The default messages are English and name the allowed schemes and hosts. Pass messages for other languages.

## Usage guide

### URL field

A web address field built on the text field's label, hint and error structure, with the rules a
website field needs: add `https://` when it is missing, refuse unsafe schemes, and optionally
restrict the host.

```svelte
<UrlField label="Website" name="website" placeholder="example.com" bind:value={website} />
```

#### Three ways to handle the scheme

- **`add-https`** (default). The person can type `northwind.studio`. On blur the field saves
  `https://northwind.studio`, shows "Added https://" under it and announces the change politely.
  A typed `http://` address is kept if `allowedProtocols` allows it.
- **`require`**. The person must type the scheme. Use it with `allowedProtocols={['https:']}` for
  webhooks and callback URLs, where a missing or plain-HTTP scheme is a mistake worth flagging.
- **`prefix-adornment`**. A fixed `https://` sits inside the border and the person types only the
  rest. The bound value and the submitted field are the full URL. Pasting a full address strips
  its scheme, so the field never reads `https://https://`.

`javascript:`, `data:` and `vbscript:` are refused in every mode, whatever `allowedProtocols`
says.

#### Restricting the host

```svelte
<UrlField
	label="GitHub profile"
	name="github"
	allowedHosts={['github.com']}
	placeholder="github.com/your-name"
/>
```

Hosts are compared on the parsed hostname, exactly or as a subdomain: `www.github.com` passes,
`github.com.example.net` and `notgithub.com` do not.

#### Messages and errors

Each failed check has a reason, `unparseable`, `protocol` or `host`. The field shows a default
English message for it, sets a custom validity so native submission stops, and calls
`onInvalid(reason)`. Pass `messages` to use your own words:

```svelte
<UrlField
	label="Sitio web"
	name="website"
	addedNotice="Se añadió https://"
	messages={{ unparseable: 'Escribe una dirección web, como ejemplo.com.' }}
/>
```

`error` is for your server's reply ("Another workspace already links to this address"). It shows
instead of a local error. The field never fetches the address or checks that it resolves.

#### Checking on submit

Blur and Enter run the checks. To run them yourself, bind the component and call `validate()`;
the same rules are exported as `checkUrl` for use anywhere else, including the server.

```svelte
<script lang="ts">
	import UrlField, { checkUrl } from './UrlField.svelte';
	let field: ReturnType<typeof UrlField>;
</script>

<UrlField bind:this={field} label="Website" name="website" />
```

#### Without JavaScript

Before hydration the field is a plain `type="url"` input, so the browser's own URL validation
applies and a bare domain is refused until the script runs. If your server normalises addresses
itself, set `novalidate` on the form. The custom checks (scheme, host) apply from the first
render once the script runs, so an untouched field with a refused value still blocks submission.
`prefix-adornment` shows its fixed prefix only after hydration; before that it is an ordinary
`type="url"` field holding the full URL, so it submits the same value either way.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label, always shown. The placeholder never stands in for it. |
| `value` | `string` | no |  | Bindable value: what the person typed, normalised on blur (https:// added, whitespace trimmed). In prefix-adornment mode it is always the full URL. |
| `name` | `string` | no |  | Form field name, so native submission includes the full URL. |
| `protocol` | 'require' \| 'add-https' \| 'prefix-adornment' | no | `'add-https'` | 'add-https' adds https:// on blur when no scheme was typed and announces it. 'require' asks the person to type the scheme. 'prefix-adornment' shows a fixed https:// and saves it in front of whatever is typed. |
| `allowedProtocols` | `string[]` | no | `['https:', 'http:']` | Permitted schemes, with or without the colon. javascript:, data: and vbscript: are rejected even if listed. |
| `allowedHosts` | `string[]` | no | `[]` | Restrict to these hosts and their subdomains, such as \['github.com'\] for a profile link. Empty allows any host. |
| `onInvalid` | (reason: 'unparseable' \| 'protocol' \| 'host') =\> void | no |  | Called on blur, Enter or validate() when the value fails a local check. |
| `hint` | `string` | no |  | Help text under the label, linked first in aria-describedby. |
| `error` | string \| null | no | `null` | Error from your code or server. Shown instead of any local error and sets aria-invalid. |
| `messages` | Partial\<Record\<'unparseable' \| 'protocol' \| 'host', string\>\> | no |  | Your message for each local reason. Unset reasons use English defaults that name the allowed schemes and hosts. |
| `addedNotice` | `string` | no | `'Added https://'` | Shown under the field and announced politely when https:// is added. |
| `prefixDescription` | `string` | no | `'Starts with https://'` | Read as part of the field's description in prefix-adornment mode, so the fixed prefix is not visual only. |
| `autocomplete` | `HTMLInputAttributes['autocomplete']` | no | `'url'` | Autofill token. Use 'off' for addresses that are not the person's own, such as a webhook. |
| `required` | `boolean` | no | `false` | Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. |
| `requiredIndicator` | 'required' \| 'optional' \| 'none' | no |  | Which marker follows the label. Defaults to 'required' for required fields and 'none' otherwise. |
| `requiredLabel` | `string` | no | `'Required'` | Marker text for required fields. |
| `optionalLabel` | `string` | no | `'Optional'` | Marker text for optional fields. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before the error. |
| `size` | 'sm' \| 'md' | no | `'md'` | 32 or 36 px tall with 13 or 14 px text, matching buttons of the same size. Both are 44 px with 16 px text on touch screens. |
| `reserveFeedback` | `boolean` | no | `true` | Hold one line under the field for the notice or a one-line error, so neither moves anything below. Set false in dense layouts. |
| `id` | `string` | no |  | Input id. Defaults to one generated per instance; the hint, prefix, notice and error ids are always generated. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

Change the rules and copy through props, and retone the field through seven --url-field-\* CSS variables. Hover, the disabled fill and the autofill tint are mixed from those, so they follow any retone.

- Rules: protocol 'add-https' suits profile and listing websites; 'require' with allowedProtocols \['https:'\] suits webhooks and callbacks; 'prefix-adornment' suits fields where the scheme is never in doubt. Add allowedHosts for profile links on one service.
- Messages: pass messages={{ unparseable, protocol, host }} in your own words or language, and translate addedNotice, prefixDescription and errorPrefix with the page.
- Validation on submit: bind:this to the field and call validate() before sending; it runs the blur checks and returns { ok, value } or { ok: false, reason }. checkUrl is exported from the module script for the same rules on the server.
- Accent: --url-field-accent colours the focus edge and its halo, and tints the autofilled fill. Keep it at 3:1 against the page.
- Danger: --url-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: --url-field-ink is the label and value, --url-field-muted the hint, prefix, marker and notice, --url-field-placeholder the placeholder, --url-field-hairline the resting edge (keep it at 3:1, since it is the only thing marking an empty field) and --url-field-surface the fill.
- Worked retone for a #09090b band: --url-field-accent: #fafafa; --url-field-ink: #fafafa; --url-field-muted: #a1a1aa; --url-field-placeholder: #a1a1aa; --url-field-hairline: rgb(255 255 255 / 0.35); --url-field-surface: #18181b; --url-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 --url-field-surface: #fffdf9.
- Layout: the field fills its container. With reserveFeedback on, stack fields 12 to 16 px apart, since each already holds a line under it; with it off, 20 to 24 px.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--url-field-accent` |
| `danger` | `--url-field-danger` |
| `ink` | `--url-field-ink` |
| `muted` | `--url-field-muted` |
| `placeholder` | `--url-field-placeholder` |
| `hairline` | `--url-field-hairline` |
| `surface` | `--url-field-surface` |

## Accessibility

- A native \<label for\> names the input, which is type=url (type=text in prefix-adornment mode) with inputmode url, autocomplete url, and spellcheck, autocorrect and autocapitalize off.
- aria-describedby lists the hint, then the prefix description (prefix-adornment mode), then the notice or the error, then any aria-describedby you pass, each only when present.
- 'Added https://' is announced through a polite status region that is always in the page. Errors are not live regions; on a failed submit, move focus to the first invalid field or an error summary.
- The local rules set a custom validity message from the first render, touched or not, so native submission stops on a failing value and the browser reports it. Blur, Enter and validate() also show the message and call onInvalid.
- The error is never colour alone: it has an icon, text and a visually hidden errorPrefix. The edge and focus ring turn to the danger colour while it shows.
- The address row stays left to right in RTL pages, because URLs read that way; the label, hint and feedback follow the page direction.
- Inputs use 16 px text and grow to 44 px on touch screens, so iOS does not zoom and targets clear 44 px.

Known limitations:

- Normalisation, the notice and the custom checks need the script. Before hydration only native type=url validation runs.

## License

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

## Source

- Palette: Blue accent (`blue`)
- Entry: `UrlField.svelte`
- Suggested directory: `src/lib/components/url-field-01`
- Files: 1
- Artifact digest: `sha256-e6833bc749a0c7574a7535ef234cfb3a3b45431de63411231dc5b558db32d381`

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

#### `UrlField.svelte`

Role: entry · 19695 bytes · SHA-256 `51066a039199529cfa60844bf5038d04362fdbc099e1933b77395a084ab4b0e3`

```svelte
<!--
	A web address field: a visible label, an optional hint, the input with an optional fixed
	https:// prefix inside the same border, and one line under it that carries either the
	normalisation notice ("Added https://") or an error. On blur it parses the address with the
	URL constructor, adds https:// when it is missing (if allowed), and checks the scheme and
	host against your rules. It never fetches the address or claims the site exists. Before any
	script runs it is a plain native type=url field, so it submits with its form and the
	browser's own URL validation applies.
-->
<script lang="ts" module>
	export type UrlFieldProtocol = 'require' | 'add-https' | 'prefix-adornment';
	export type UrlFieldReason = 'unparseable' | 'protocol' | 'host';
	export type UrlFieldSize = 'sm' | 'md';
	export type UrlFieldRequiredIndicator = 'required' | 'optional' | 'none';
	export type UrlFieldMessages = Partial<Record<UrlFieldReason, string>>;
	export type UrlFieldCheck =
		| { ok: true; value: string; added: boolean }
		| { ok: false; reason: UrlFieldReason; value: string };
	export interface UrlFieldRules {
		protocol?: UrlFieldProtocol;
		allowedProtocols?: readonly string[];
		allowedHosts?: readonly string[];
	}

	/* Schemes that run code or carry their own document. Rejected whatever allowedProtocols says. */
	const BLOCKED = new Set(['javascript:', 'data:', 'vbscript:']);
	/* The schemes a prefix stands for; anything else typed in prefix mode is refused, not rewritten. */
	const WEB = /^https?:\/\//i;
	/*
	 * Any "scheme:" at the start, written with or without "//" (mailto:, ftp:mira@…), except a
	 * port: "localhost:3000" and "example.com:8080/x" are a host, so they still get https://.
	 */
	const hasScheme = (text: string) => /^[a-z][a-z\d+.-]*:(?!\d+(?:[/?#]|$))/i.test(text);
	/* Zero-width characters that ride along with copied links. */
	const INVISIBLE = new RegExp('[\\u200B-\\u200D\\u2060\\uFEFF]', 'g');

	const toProtocol = (scheme: string) => scheme.toLowerCase().replace(/:?$/, ':');

	/** Hostnames as the URL parser writes them: lowercase, punycode, no trailing dot. */
	function toHost(host: string): string | null {
		try {
			return new URL(`https://${host.trim()}`).hostname.replace(/\.$/, '');
		} catch {
			return null;
		}
	}

	/**
	 * The check the field runs on blur, exported so a form or server can run the same rules.
	 * Empty is not invalid here; use required for that. Hosts match exactly or as a subdomain,
	 * so github.com accepts www.github.com and never github.com.example.net.
	 */
	export function checkUrl(input: string, rules: UrlFieldRules = {}): UrlFieldCheck {
		const {
			protocol = 'add-https',
			allowedProtocols = ['https:', 'http:'],
			allowedHosts = []
		} = rules;
		const trimmed = input.replace(INVISIBLE, '').trim();
		if (!trimmed) return { ok: true, value: '', added: false };

		/*
		 * The URL parser ignores tabs and newlines anywhere, so "java\nscript:" is javascript:. Read
		 * the scheme the same way, and refuse the blocked ones before any prefix is added or removed.
		 */
		const bare = trimmed.replace(/[\t\n\r]/g, '');
		if (/^(?:javascript|data|vbscript):/i.test(bare)) {
			return { ok: false, reason: 'protocol', value: trimmed };
		}

		let text = bare;
		let added = false;
		if (protocol === 'prefix-adornment') {
			if (hasScheme(bare) && !WEB.test(bare)) {
				return { ok: false, reason: 'protocol', value: trimmed };
			}
			text = `https://${bare.replace(WEB, '')}`;
		} else if (!hasScheme(bare)) {
			if (protocol === 'require') return { ok: false, reason: 'protocol', value: trimmed };
			text = `${bare.startsWith('//') ? 'https:' : 'https://'}${bare}`;
			added = true;
		}

		let url: URL;
		try {
			url = new URL(text);
		} catch {
			return { ok: false, reason: 'unparseable', value: trimmed };
		}

		const allowed = allowedProtocols.map(toProtocol);
		if (BLOCKED.has(url.protocol) || !allowed.includes(url.protocol)) {
			return { ok: false, reason: 'protocol', value: text };
		}

		/* A web address needs a host with a dot in it ("examplecom" is a typo, not a site). */
		const host = url.hostname.replace(/\.$/, '');
		if (/^https?:$/.test(url.protocol)) {
			const looksLikeHost = host.includes('.') || host === 'localhost' || host.startsWith('[');
			if (!looksLikeHost) return { ok: false, reason: 'unparseable', value: trimmed };
		}

		if (allowedHosts.length) {
			const hosts = allowedHosts.map(toHost).filter((h): h is string => !!h);
			if (!hosts.some((h) => host === h || host.endsWith(`.${h}`))) {
				return { ok: false, reason: 'host', value: text };
			}
		}

		return { ok: true, value: text, added };
	}
</script>

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

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

	interface Props extends Passthrough {
		/** Visible label. Always shown; the placeholder never stands in for it. */
		label: string;
		/** Bindable value: what the person typed, normalised on blur. Always the full URL. */
		value?: string;
		/** Form field name for native submission. */
		name?: string;
		/** 'add-https' adds https:// on blur, 'require' asks for the scheme, 'prefix-adornment' shows a fixed https://. */
		protocol?: UrlFieldProtocol;
		/** Permitted schemes. javascript:, data: and vbscript: are always rejected. */
		allowedProtocols?: readonly string[];
		/** Restrict to these hosts and their subdomains, such as ['github.com']. */
		allowedHosts?: readonly string[];
		/** Called on blur, Enter or validate() when the value fails the local checks. */
		onInvalid?: (reason: UrlFieldReason) => void;
		/** Help text under the label, linked with aria-describedby. */
		hint?: string;
		/** Error from the consumer, such as a server reply. Shown instead of any local error. */
		error?: string | null;
		/** Messages for the local checks, in your own words or language. */
		messages?: UrlFieldMessages;
		/** The notice shown and announced when https:// is added. */
		addedNotice?: string;
		/** Read with the field in prefix-adornment mode, so the fixed prefix is not visual only. */
		prefixDescription?: string;
		/** Autofill token. Defaults to 'url'. */
		autocomplete?: HTMLInputAttributes['autocomplete'];
		/** 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?: UrlFieldRequiredIndicator;
		/** 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 tall, matching action buttons; 44 px on touch screens. */
		size?: UrlFieldSize;
		/** Hold one line under the field for the notice or error, so neither moves anything below. */
		reserveFeedback?: 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,
		protocol = 'add-https',
		allowedProtocols = ['https:', 'http:'],
		allowedHosts = [],
		onInvalid,
		hint,
		error = null,
		messages,
		addedNotice = 'Added https://',
		prefixDescription = 'Starts with https://',
		autocomplete = 'url',
		required = false,
		requiredIndicator,
		requiredLabel = 'Required',
		optionalLabel = 'Optional',
		errorPrefix = 'Error:',
		size = 'md',
		reserveFeedback = true,
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const hintId = `${uid}-hint`;
	const prefixId = `${uid}-prefix`;
	const noticeId = `${uid}-notice`;
	const errorId = `${uid}-error`;

	/* After hydration, prefix mode shows a fixed https:// and submits through a hidden input. Before it, the field is a plain named type=url input holding the full URL. */
	let hydrated = $state(false);
	onMount(() => {
		hydrated = true;
	});
	const prefixed = $derived(protocol === 'prefix-adornment' && hydrated);
	const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));

	/* The local result of the last check: a reason while it failed, and whether https:// was added. */
	let reason: UrlFieldReason | null = $state(null);
	let added = $state(false);
	const schemes = $derived(
		allowedProtocols
			.map((p) => p.toLowerCase().replace(/:?$/, ''))
			.filter((p) => p !== 'javascript' && p !== 'data' && p !== 'vbscript')
			.map((p) => (p === 'http' || p === 'https' ? `${p}://` : `${p}:`))
	);
	const defaults = $derived({
		unparseable:
			protocol === 'require'
				? 'Enter a full web address, such as https://example.com'
				: 'Enter a web address, such as example.com',
		protocol: `Start the address with ${schemes.join(' or ')}`,
		host: `Use a ${allowedHosts.join(' or ')} address`
	});
	const messageFor = (r: UrlFieldReason) => messages?.[r] ?? defaults[r];
	const localError = $derived(reason ? messageFor(reason) : null);
	const shownError = $derived(error || localError);
	const invalid = $derived(!!shownError);
	const noticed = $derived(added && !invalid);

	/* Hint, then the fixed prefix, then the notice or error, then anything the consumer attached. */
	const describedBy = $derived(
		[
			...new Set(
				[
					hint && hintId,
					prefixed && prefixId,
					noticed && noticeId,
					invalid && errorId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ') || undefined
	);

	/* In prefix mode the person edits only what follows https://. */
	const display = (url: string) => (prefixed ? url.replace(WEB, '') : url);

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

	/*
	 * Writes from the input: typing, a form reset, and edits made before hydration all land here
	 * through bind:value, so the visible text, the bound value and the hidden input agree.
	 */
	function write(text: string) {
		/* A pasted full URL loses its scheme, so the field never reads https://https://. */
		const rest = prefixed ? text.trimStart().replace(WEB, '') : text;
		/* Any other scheme stays as typed, so the check refuses it rather than hiding it behind https://. */
		const foreign = hasScheme(rest);
		value = prefixed && !foreign ? (rest ? `https://${rest}` : '') : rest;
		added = false;
		/* An error clears as soon as the address passes; a new one waits for blur. */
		if (reason && check(value).ok) reason = null;
	}

	const check = (text: string) => checkUrl(text, { protocol, allowedProtocols, allowedHosts });

	/*
	 * Native submission follows the rules from the first render, touched or not: a failing value
	 * blocks the form with its message even before blur shows it.
	 */
	$effect(() => {
		const result = check(value ?? '');
		input?.setCustomValidity(result.ok ? '' : messageFor(result.reason));
	});

	/**
	 * Run the checks now, as blur and Enter do: normalise the value, show any error, call
	 * onInvalid and block native submission while it fails. Returns the result.
	 */
	export function validate(): UrlFieldCheck {
		const result = check(value ?? '');
		if (result.ok || result.reason !== 'unparseable') value = result.value;
		added = result.ok && result.added;
		reason = result.ok ? null : result.reason;
		/* Enter submits straight after this handler, so the input must hold the new state now. */
		if (input) {
			input.value = display(value ?? '');
			input.setCustomValidity(result.ok ? '' : messageFor(result.reason));
		}
		if (!result.ok) onInvalid?.(result.reason);
		return result;
	}

	function onkeydown(event: KeyboardEvent) {
		if (event.key === 'Enter' && !event.isComposing) validate();
	}

	/* Schemes and hosts inside messages, split out so each can read left to right in RTL copy. */
	const fragments = (text: string) =>
		text.split(/((?:[a-z][a-z\d+.-]*:\/\/)?[\w-]+(?:\.[\w-]+)+\/?|[a-z][a-z\d+.-]*:\/\/)/i);

	/* A press on the prefix or the border focuses the input, as a press inside one box should. */
	function focusInput(event: PointerEvent) {
		if (!input || event.button !== 0 || event.target === input) return;
		if (input.matches(':disabled')) return;
		event.preventDefault();
		input.focus();
	}
</script>

<!-- An address inside a sentence keeps its own order, so "נוסף https://" never reads "//:https". -->
{#snippet isolated(text: string)}{#each fragments(text) as part, i (i)}{#if i % 2}<bdi dir="ltr"
				>{part}</bdi
			>{:else}{part}{/if}{/each}{/snippet}

<div
	class={['url-field min-w-0', className]}
	data-size={size}
	data-invalid={invalid ? '' : undefined}
>
	<label
		for={inputId}
		class={[
			'block leading-5 font-medium text-pretty break-words text-(--_ink)',
			size === 'sm' ? 'text-[13px]' : 'text-sm'
		]}
	>
		{label}{#if marker === 'required'}
			<!-- The native required attribute already says so to assistive technology. -->
			<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
				>&ensp;{requiredLabel}</span
			>{:else if marker === 'optional'}
			<span class="text-[13px] font-normal text-(--_muted)">&ensp;{optionalLabel}</span>{/if}
	</label>
	{#if hint}
		<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
			{hint}
		</p>
	{/if}

	<!-- Addresses read left to right in every language, so the row keeps that order in RTL pages. -->
	<!-- svelte-ignore a11y_no_static_element_interactions -->
	<div
		dir="ltr"
		class={[
			'url-field__control mt-2 flex min-h-(--_h) items-stretch border',
			'border-(--_edge) bg-(--_surface) text-(--_ink)',
			size === 'sm' ? 'rounded-md' : 'rounded-lg'
		]}
		onpointerdown={focusInput}
	>
		{#if prefixed}
			<span
				class={[
					'flex shrink-0 cursor-text items-center ps-3 whitespace-nowrap text-(--_muted) select-none',
					'pointer-coarse:text-base',
					size === 'sm' ? 'text-[13px] leading-5' : 'text-sm leading-5'
				]}
				aria-hidden="true">https://</span
			>
			<span id={prefixId} class="sr-only">{prefixDescription}</span>
		{/if}
		<input
			bind:this={input}
			{...rest}
			bind:value={() => display(value ?? ''), write}
			type={prefixed ? 'text' : 'url'}
			inputmode="url"
			name={prefixed ? undefined : name}
			{autocomplete}
			{required}
			spellcheck={false}
			autocapitalize="none"
			autocorrect="off"
			id={inputId}
			aria-describedby={describedBy}
			aria-invalid={invalid ? 'true' : undefined}
			class={[
				'url-field__input w-full min-w-0 flex-1 truncate bg-transparent text-(--_ink) outline-none',
				'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
				'pointer-coarse:text-base',
				size === 'sm' ? 'text-[13px] leading-5' : 'text-sm leading-5',
				prefixed ? 'ps-0 pe-3' : 'px-3'
			]}
			onblur={() => {
				if (!input?.readOnly) validate();
			}}
			{onkeydown}
		/>
		{#if prefixed && name}
			<input type="hidden" {name} value={value ?? ''} form={rest.form} disabled={rest.disabled} />
		{/if}
	</div>

	<!-- One cell holds the notice and the error, so swapping them never changes the height. -->
	<div class={['grid', reserveFeedback && 'min-h-6']}>
		<p
			id={noticeId}
			role="status"
			class={[
				'col-start-1 row-start-1 flex items-start gap-1 text-[13px] leading-5 text-(--_muted)',
				noticed && 'mt-1'
			]}
		>
			{#if noticed}<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"
						><path
							d="M3.5 8.25 6.5 11.25 12.5 4.75"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
						/></svg
					></span
				><span class="min-w-0">{@render isolated(addedNotice)}</span>{/if}
		</p>
		{#if invalid}
			<p
				id={errorId}
				class="col-start-1 row-start-1 mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_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>{@render isolated(
						shownError ?? ''
					)}</span
				>
			</p>
		{/if}
	</div>
</div>

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

		--_h: 2.25rem;
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
	}
	.url-field[data-size='sm'] {
		--_h: 2rem;
	}
	@media (pointer: coarse) {
		.url-field[data-size] {
			--_h: 2.75rem;
		}
	}

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

	/* The edge, fill and halo follow the input inside, so their transition lives with them. */
	.url-field__control {
		transition-property: border-color, box-shadow, background-color;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	@media (prefers-reduced-motion: reduce) {
		.url-field__control {
			transition-duration: 0s;
		}
	}

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

	/* Focus: a two-pixel edge in the accent (one border plus one ring), with a soft halo outside. */
	.url-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 and disabled: a raised fill; disabled also fades and stops responding. */
	.url-field__control:has(input[readonly]),
	.url-field__control:has(input:disabled) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}
	.url-field__control:has(input:disabled) {
		opacity: 0.55;
		cursor: not-allowed;
	}
	.url-field__control:has(input:disabled) > span {
		cursor: not-allowed;
	}

	/* Autofill: keep the input transparent and tint the whole shape, prefix included. */
	.url-field__input:autofill {
		-webkit-text-fill-color: var(--_ink);
		transition: background-color 0s 86400s;
	}
	.url-field__control:has(input:autofill) {
		background-color: color-mix(in oklab, var(--_surface), var(--_accent) 6%);
	}

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

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-e6833bc749a0c7574a7535ef234cfb3a3b45431de63411231dc5b558db32d381`
- Entry: `UrlField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_url_field_01/1.0.0/blue/sha256-e6833bc749a0c7574a7535ef234cfb3a3b45431de63411231dc5b558db32d381/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_url_field_01/1.0.0/blue/sha256-e6833bc749a0c7574a7535ef234cfb3a3b45431de63411231dc5b558db32d381/bundle.zip (11295 bytes, sha256 `0e98a0723f03e0a7dcd0080b84e1c39865c69dd45ca6edecdc33cb440ed3ca22`)

Files:

- `UrlField.svelte` (entry, 19695 bytes): https://pagesugar.com/artifacts/cmp_url_field_01/1.0.0/blue/sha256-e6833bc749a0c7574a7535ef234cfb3a3b45431de63411231dc5b558db32d381/source/UrlField.svelte
