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.
cmp_url_field_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_url_field_01 version 1.0.0 with variant "neutral", 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
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-3a669ebf29520d9fe4dfd58e12e01ce78165ded5ee7a8f916e8cf45da4ca3a7b
<!--
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"
> {requiredLabel}</span
>{:else if marker === 'optional'}
<span class="text-[13px] font-normal text-(--_muted)"> {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, #18181b);
--_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>
Usage#
On this pageGive 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.
- Suggested location
src/lib/components/url-field-01- Required props
label
Limitations
- Before the page hydrates, the field is a plain
type=urlinput: the browser's own validation applies, so a bare domain likeexample.comis 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=urlfield holding the full URL. Once hydrated it shows the fixed https://, the visibletype=textinput 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.
Example
<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>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.
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.
<UrlField label="Website" name="website" placeholder="example.com" bind:value={website} />Three ways to handle the scheme#
add-https(default). The person can typenorthwind.studio. On blur the field saveshttps://northwind.studio, shows "Added https://" under it and announces the change politely. A typedhttp://address is kept ifallowedProtocolsallows it.require. The person must type the scheme. Use it withallowedProtocols={['https:']}for webhooks and callback URLs, where a missing or plain-HTTP scheme is a mistake worth flagging.prefix-adornment. A fixedhttps://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 readshttps://https://.
javascript:, data: and vbscript: are refused in every mode, whatever allowedProtocols
says.
Restricting the host#
<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:
<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.
<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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label, always shown. The placeholder never stands in for it. |
value | string | No | None | 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 | None | 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 | None | Called on blur, Enter or validate() when the value fails a local check. |
hint | string | No | None | 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 | None | 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 | None | 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 | None | Input id. Defaults to one generated per instance; the hint, prefix, notice and error ids are always generated. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange 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. AddallowedHostsfor profile links on one service. - Messages: pass messages={
{ unparseable, protocol, host }} in your own words or language, and translateaddedNotice,prefixDescriptionanderrorPrefixwith 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 }.checkUrlis exported from the module script for the same rules on the server. - Accent:
--url-field-accentcolours the focus edge and its halo, and tints the autofilled fill. Keep it at 3:1 against the page. - Danger:
--url-field-dangercolours the error edge, the error focus ring, the icon and the message. The default isred-700(6.5:1 on white). - Neutrals:
--url-field-inkis the label and value,--url-field-mutedthe hint, prefix, marker and notice,--url-field-placeholderthe placeholder,--url-field-hairlinethe resting edge (keep it at 3:1, since it is the only thing marking an empty field) and--url-field-surfacethe fill. - Worked retone for a
#09090bband:--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
reserveFeedbackon, stack fields 12 to 16 px apart, since each already holds a line under it; with it off, 20 to 24 px.
Public CSS variables
| Variable | Token |
|---|---|
--url-field-accent | accent |
--url-field-danger | danger |
--url-field-ink | ink |
--url-field-muted | muted |
--url-field-placeholder | placeholder |
--url-field-hairline | hairline |
--url-field-surface | surface |
Accessibility#
On this page- A native <label for> names the input, which is
type=url(type=textin prefix-adornment mode) with inputmode url, autocomplete url, and spellcheck, autocorrect and autocapitalize off. aria-describedbylists the hint, then the prefix description (prefix-adornment mode), then the notice or the error, then anyaria-describedbyyou 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=urlvalidation runs.
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 · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.