Skip to content
Download ZIP

Neutral palette · 11.2 KB ZIP File receipt View as Markdown View code

Preview

Demonstration — no data is sent

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_slug_field_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_slug_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-6ccd85b2a0472144d50ba65809d65f928479764b9d6458d39639a79769306f07
SlugField.svelte Svelte · 22.2 KB Raw
<!--
	A slug field: a path-friendly identifier (lowercase letters, numbers and hyphens) that follows
	a source such as the title until the person edits it. The input and the finished address are
	one shape: the slug on top and, inside the same border, a strip showing the full URL with the
	base path muted and the slug in ink. An optional availability check runs debounced, aborts
	stale requests and reports on one reserved status line. The check is advisory; the server
	still has to enforce uniqueness on save.
-->
<script lang="ts" module>
	export interface SlugAvailability {
		available: boolean;
		/** Replaces the default status text. */
		message?: string;
		/** A free slug to offer when this one is taken. */
		suggestion?: string;
	}

	export type SlugAvailabilityCheck = (
		slug: string,
		signal: AbortSignal
	) => Promise<SlugAvailability>;

	export interface SlugFieldMessages {
		/** Shown in place of the regenerate button while the slug follows its source. */
		synced: string;
		/** The regenerate button, which makes the slug follow its source again. */
		regenerate: string;
		checking: string;
		available: string;
		taken: string;
		failed: string;
		/** Status after a blur changed what was typed. */
		normalised: string;
		/** Visible suggestion button text; {slug} is replaced. */
		useSuggestion: string;
		/** The suggestion button's accessible name; {slug} is replaced. */
		useSuggestionLabel: string;
		/** Read before the error by screen readers only. */
		errorPrefix: string;
	}

	const DEFAULT_MESSAGES: SlugFieldMessages = {
		synced: 'Follows the title',
		regenerate: 'Regenerate from title',
		checking: 'Checking availability…',
		available: 'Available',
		taken: 'Already in use',
		failed: 'Couldn’t check availability.',
		normalised: 'Adjusted to a–z, 0–9 and hyphens.',
		useSuggestion: 'Use {slug}',
		useSuggestionLabel: 'Use suggested slug: {slug}',
		errorPrefix: 'Error:'
	};

	/* Letters NFKD does not decompose into a base letter and a mark. */
	const FOLD: Record<string, string> = {
		ß: 'ss',
		æ: 'ae',
		œ: 'oe',
		ø: 'o',
		đ: 'd',
		ð: 'd',
		þ: 'th',
		ł: 'l',
		ı: 'i'
	};

	/**
	 * The default slugify: NFKD, strip diacritics, lowercase, every run of anything other than
	 * a-z and 0-9 becomes one hyphen, hyphens trimmed. 'Hello, Wörld! 2025' → 'hello-world-2025'.
	 * Scripts without a Latin decomposition (Cyrillic, Greek, CJK) are dropped, not transliterated.
	 */
	export function toSlug(input: string): string {
		return input
			.normalize('NFKD')
			.replace(/\p{M}+/gu, '')
			.toLowerCase()
			.replace(/[ßæœøđðþłı]/g, (c) => FOLD[c])
			.replace(/[^a-z0-9]+/g, '-')
			.replace(/^-+|-+$/g, '');
	}

	/** Cut a slug to max characters at a hyphen boundary where there is one, never ending in a hyphen. */
	export function fitSlug(slug: string, max: number): string {
		const trimmed = slug.replace(/^-+|-+$/g, '');
		if (trimmed.length <= max) return trimmed;
		const cut = trimmed.slice(0, max);
		const boundary = trimmed[max] === '-' ? max : cut.lastIndexOf('-');
		return (boundary > 0 ? cut.slice(0, boundary) : cut).replace(/-+$/, '');
	}
</script>

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

	type Passthrough = Omit<
		HTMLInputAttributes,
		| 'type'
		| 'value'
		| 'id'
		| 'class'
		| 'children'
		| 'maxlength'
		| 'aria-invalid'
		| 'aria-describedby'
		| 'oninput'
		| 'onblur'
	>;

	type Status =
		| { kind: 'idle' }
		| { kind: 'checking' }
		| { kind: 'available'; message?: string }
		| { kind: 'taken'; message?: string; suggestion?: string }
		| { kind: 'failed'; message?: string };

	interface Props extends Passthrough {
		/** Visible label. */
		label: string;
		/** Bindable slug. */
		value?: string;
		/** Text to derive from, such as the title. Derivation stops once the person edits the slug. */
		source?: string;
		/** Address before the slug in the URL strip, such as 'halcyon.app/blog/'. Omit for no strip. */
		basePath?: string;
		/** Longest slug allowed. Longer derivations are cut at a hyphen boundary. */
		maxLength?: number;
		/** Advisory uniqueness check, debounced; the signal aborts when the slug changes. */
		checkAvailability?: SlugAvailabilityCheck;
		/** Milliseconds of quiet after typing before the check runs. */
		checkDelay?: number;
		/** Replaces the default transliterate-and-hyphenate function. */
		slugify?: (input: string) => string;
		/** Error from your code, such as a failed save. Sets aria-invalid. */
		error?: string | null;
		/** Help text under the label. Defaults to the allowed characters and the length limit. */
		hint?: string;
		/** Status and button text, for other languages or another source than a title. */
		messages?: Partial<SlugFieldMessages>;
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		value = $bindable(),
		source,
		basePath,
		maxLength = 60,
		checkAvailability,
		checkDelay = 400,
		slugify,
		error = null,
		hint,
		messages,
		id,
		class: className,
		disabled,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const hintId = `${uid}-hint`;
	const previewId = `${uid}-preview`;
	const statusId = `${uid}-status`;
	const errorId = `${uid}-error`;

	const t = $derived({ ...DEFAULT_MESSAGES, ...messages });
	const hintText = $derived(hint ?? `a–z, 0–9 and hyphens, up to ${maxLength} characters.`);
	const breakable = (text: string) => text.split(/(?<=[/.-])/);
	const fill = (template: string, slug: string) => template.replaceAll('{slug}', slug);

	const normalise = (input: string) => fitSlug((slugify ?? toSlug)(input), maxLength);

	/*
	 * Follows the source until edited. A supplied value that differs from the derivation counts as
	 * an edit, so an existing record keeps its slug; an empty field picks up a source that arrives late.
	 */
	let edited = $state(
		untrack(() => !!value && (source === undefined || value !== normalise(source)))
	);
	const following = $derived(!edited && source !== undefined);
	/* Computed rather than written, so the server render already shows the derived slug. */
	const current = $derived(following ? normalise(source ?? '') : (value ?? ''));
	/*
	 * The last value the field settled on (derived, normalised on blur, a suggestion or supplied).
	 * It is never run through slugify again, so a custom slugify need not be idempotent.
	 */
	let settled = $state(untrack(() => value ?? ''));
	const slug = $derived(
		following || (current === settled && current.length <= maxLength) ? current : normalise(current)
	);

	/* Writes the derivation to the bound value; a different value from the parent is an edit. */
	let written = untrack(() => value);
	$effect(() => {
		const next = current;
		if (!following) return;
		if (value && value !== written && value !== next) {
			edited = true;
			settled = value;
			return;
		}
		if (value !== next) {
			written = next;
			value = next;
		}
		settled = next;
	});

	let result: { slug: string; status: Status } | null = $state(null);
	let mounted = $state(false);
	let normalisedNote = $state(false);
	let input: HTMLInputElement | undefined = $state();

	/*
	 * The status describes the current slug only: a verdict for an earlier slug reads as checking
	 * until the new one settles, so an old suggestion can never be pressed for a new address.
	 */
	const status = $derived.by((): Status => {
		if (!checkAvailability || !slug || !mounted) return { kind: 'idle' };
		return result?.slug === slug ? result.status : { kind: 'checking' };
	});

	/*
	 * The check: a slug present at mount goes at once, every later one after checkDelay of quiet.
	 * Every change aborts the request in flight, and a reply is only applied while its signal is
	 * live, so a slow reply for an old slug never overwrites a newer one.
	 */
	let firstCheck = true;
	$effect(() => {
		mounted = true;
		const target = slug;
		const check = checkAvailability;
		const immediate = firstCheck;
		firstCheck = false;
		if (!check || !target) return;
		const controller = new AbortController();
		const timer = setTimeout(
			async () => {
				try {
					const reply = await check(target, controller.signal);
					if (controller.signal.aborted) return;
					result = {
						slug: target,
						status: reply.available
							? { kind: 'available', message: reply.message }
							: { kind: 'taken', message: reply.message, suggestion: reply.suggestion }
					};
				} catch {
					if (controller.signal.aborted) return;
					result = { slug: target, status: { kind: 'failed' } };
				}
			},
			immediate ? 0 : checkDelay
		);
		return () => {
			clearTimeout(timer);
			controller.abort();
		};
	});

	/* A suggestion that normalises back to the taken slug (or to nothing) is not offered. */
	const suggestion = $derived.by(() => {
		if (status.kind !== 'taken' || !status.suggestion) return '';
		const next = normalise(status.suggestion);
		return next === slug ? '' : next;
	});
	/* The visible text around the slug, so the slug alone is set in mono. */
	const suggestionText = $derived.by(() => {
		const [before = '', ...after] = t.useSuggestion.split('{slug}');
		return [before, after.join(suggestion)];
	});
	const invalid = $derived(!!error);
	const describedBy = $derived(
		[
			basePath !== undefined && previewId,
			(hintText || normalisedNote) && hintId,
			invalid ? errorId : statusId
		]
			.filter(Boolean)
			.join(' ')
	);

	function onInput(event: Event & { currentTarget: HTMLInputElement }) {
		edited = true;
		normalisedNote = false;
		value = event.currentTarget.value;
	}

	/* Normalised on blur rather than per keystroke, so the caret never jumps while typing. */
	function onBlur(event: FocusEvent & { currentTarget: HTMLInputElement }) {
		const raw = event.currentTarget.value;
		if (raw === settled && raw.length <= maxLength) return;
		const next = normalise(raw);
		settled = next;
		if (next === raw) return;
		value = next;
		event.currentTarget.value = next;
		normalisedNote = raw.trim() !== '';
	}

	function regenerate() {
		written = value;
		edited = false;
		normalisedNote = false;
		input?.focus();
	}

	function useSuggestion() {
		if (!suggestion) return;
		edited = true;
		normalisedNote = false;
		settled = suggestion;
		value = suggestion;
		input?.focus();
	}

	/* A press on the field row's padding focuses the input, as a press inside one box should. */
	function focusInput(event: PointerEvent) {
		if (!input || event.button !== 0 || event.target === input || input.disabled) return;
		event.preventDefault();
		input.focus();
	}

	const statusTone = $derived(
		status.kind === 'available'
			? 'text-(--_success)'
			: status.kind === 'taken'
				? 'text-(--_danger)'
				: 'text-(--_muted)'
	);
</script>

<div
	class={['slug-field min-w-0', className]}
	data-invalid={invalid ? '' : undefined}
	data-status={status.kind}
	data-synced={following ? '' : undefined}
>
	<div class="flex min-h-5 flex-wrap items-start justify-between gap-x-4">
		<label for={inputId} class="text-sm leading-5 font-medium text-pretty break-words text-(--_ink)"
			>{label}</label
		>
		{#if source !== undefined}
			<!-- One slot: the marker while the slug follows its source, the button once it is edited. -->
			{#if following}
				<span class="inline-flex items-center gap-1 text-[13px] leading-5 text-(--_muted)">
					<svg class="size-3.5 shrink-0" viewBox="0 0 16 16" fill="none" aria-hidden="true">
						<path
							d="M6.5 9.5a2.75 2.75 0 0 0 3.9 0l2.1-2.1a2.75 2.75 0 0 0-3.9-3.9l-.6.6M9.5 6.5a2.75 2.75 0 0 0-3.9 0l-2.1 2.1a2.75 2.75 0 0 0 3.9 3.9l.6-.6"
							stroke="currentColor"
							stroke-width="1.5"
							stroke-linecap="round"
							stroke-linejoin="round"
						/>
					</svg>
					{t.synced}
				</span>
			{:else}
				<button
					type="button"
					class={[
						'slug-field__action relative inline-flex items-center gap-1 rounded-sm text-[13px] leading-5 font-medium text-(--_muted)',
						'transition-[color,scale] duration-150 ease-(--_ease) hover:text-(--_ink) motion-reduce:transition-none',
						'active:scale-[.98] active:duration-75 motion-reduce:active:scale-100',
						'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)',
						'disabled:cursor-not-allowed disabled:opacity-55'
					]}
					{disabled}
					onclick={regenerate}
				>
					<svg
						class="size-3.5 shrink-0 rtl:-scale-x-100"
						viewBox="0 0 16 16"
						fill="none"
						aria-hidden="true"
					>
						<path
							d="M13.25 8a5.25 5.25 0 1 1-1.54-3.71M13.25 2.75v2.5h-2.5"
							stroke="currentColor"
							stroke-width="1.5"
							stroke-linecap="round"
							stroke-linejoin="round"
						/>
					</svg>
					{t.regenerate}
				</button>
			{/if}
		{/if}
	</div>
	<div
		class="slug-field__control mt-2 overflow-hidden rounded-lg border border-(--_edge) bg-(--_surface)"
	>
		<!-- svelte-ignore a11y_no_static_element_interactions -->
		<div class="slug-field__row flex min-h-(--_h) items-stretch" onpointerdown={focusInput}>
			<input
				bind:this={input}
				{...rest}
				{disabled}
				type="text"
				id={inputId}
				value={current}
				maxlength={maxLength}
				dir="ltr"
				autocapitalize="none"
				autocomplete="off"
				spellcheck={false}
				aria-describedby={describedBy || undefined}
				aria-invalid={invalid ? 'true' : undefined}
				class={[
					'slug-field__input min-w-0 flex-1 bg-transparent px-3 font-mono text-sm leading-5 text-(--_ink) outline-none',
					'placeholder:text-(--_placeholder) disabled:cursor-not-allowed pointer-coarse:text-base'
				]}
				oninput={onInput}
				onblur={onBlur}
			/>
		</div>
		{#if basePath !== undefined}
			<!-- The finished address, always left to right: a URL reads that way in every language. -->
			<p
				id={previewId}
				dir="ltr"
				class="slug-field__preview border-t border-(--_divider) bg-(--_raised) px-3 py-2 text-start font-mono text-[13px] leading-5 break-words"
			>
				<!-- Break opportunities after / . and -, so a long address wraps between words. -->
				<span class="text-(--_muted)"
					>{#each breakable(basePath) as part, i (i)}{part}<wbr />{/each}</span
				><span class={slug ? 'text-(--_ink)' : 'text-(--_muted)'}
					>{#each breakable(slug || '…') as part, i (i)}{part}<wbr />{/each}</span
				>
			</p>
		{/if}
	</div>

	<!--
		The hint sits under the address it constrains. After a blur changed what was typed, the same
		line says so, politely announced, and stays until the next edit whatever the check reports.
	-->
	{#if hintText || normalisedNote}
		<p
			id={hintId}
			aria-live="polite"
			class="mt-1 flex min-h-5 items-start gap-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)"
		>
			{#if normalisedNote}
				<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.5" />
						<path
							d="M8 7.25v4M8 4.75v.01"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
						/>
					</svg>
				</span>
				<span class="min-w-0">{t.normalised}</span>
			{:else}
				{hintText}
			{/if}
		</p>
	{/if}

	<!-- One reserved line for status and error, stacked in one cell so neither moves the page. -->
	<div class="mt-1 grid min-h-6 text-[13px] leading-5">
		<p
			id={statusId}
			role="status"
			class={[
				'col-start-1 row-start-1 flex min-w-0 items-center gap-x-2',
				statusTone,
				invalid && 'invisible'
			]}
		>
			{#if !invalid}
				{#if status.kind === 'checking'}
					<span class="inline-flex items-center gap-1">
						<svg
							class="slug-field__spinner size-3.5 shrink-0"
							viewBox="0 0 16 16"
							fill="none"
							aria-hidden="true"
						>
							<circle
								cx="8"
								cy="8"
								r="6"
								stroke="currentColor"
								stroke-opacity="0.25"
								stroke-width="1.75"
							/>
							<path
								d="M14 8a6 6 0 0 0-6-6"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
							/>
						</svg>
						{t.checking}
					</span>
				{:else if status.kind === 'available'}
					<span class="inline-flex min-w-0 items-start gap-1">
						<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.5l3 3 6-7"
									stroke="currentColor"
									stroke-width="1.75"
									stroke-linecap="round"
									stroke-linejoin="round"
								/>
							</svg>
						</span>
						<span class="min-w-0 text-pretty">{status.message ?? t.available}</span>
					</span>
				{:else if status.kind === 'taken'}
					<span class="inline-flex min-w-0 items-start gap-1">
						<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.5" />
								<path
									d="M5.5 10.5l5-5"
									stroke="currentColor"
									stroke-width="1.5"
									stroke-linecap="round"
								/>
							</svg>
						</span>
						<span class="min-w-0 text-pretty">{status.message ?? t.taken}</span>
					</span>
					{#if suggestion}
						<button
							type="button"
							class={[
								'slug-field__suggest relative inline-flex h-6 max-w-1/2 shrink-0 items-center rounded-md border border-(--_divider) bg-(--_surface) px-2 text-[13px] leading-5 font-medium text-(--_ink)',
								'transition-[background-color,border-color,scale] duration-150 ease-(--_ease) active:duration-75 motion-reduce:transition-none',
								'hover:border-(--_edge-hover) hover:bg-(--_raised)',
								'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)',
								'active:scale-[.98] motion-reduce:active:scale-100',
								'disabled:cursor-not-allowed disabled:opacity-55'
							]}
							aria-label={fill(t.useSuggestionLabel, suggestion)}
							{disabled}
							onclick={useSuggestion}
						>
							<span class="truncate"
								>{suggestionText[0]}<span class="font-mono font-normal" dir="ltr">{suggestion}</span
								>{suggestionText[1]}</span
							>
						</button>
					{/if}
				{:else if status.kind === 'failed'}
					<span class="inline-flex min-w-0 items-start gap-1">
						<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="M8 2.75l5.5 9.75h-11L8 2.75z"
									stroke="currentColor"
									stroke-width="1.5"
									stroke-linejoin="round"
								/>
								<path
									d="M8 6.75v2.5M8 11v.01"
									stroke="currentColor"
									stroke-width="1.75"
									stroke-linecap="round"
								/>
							</svg>
						</span>
						<span class="min-w-0 text-pretty">{status.message ?? t.failed}</span>
					</span>
				{/if}
			{/if}
		</p>
		{#if invalid}
			<p id={errorId} class="col-start-1 row-start-1 flex items-start gap-1 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">{`${t.errorPrefix} `}</span>{error}</span
				>
			</p>
		{/if}
	</div>
</div>

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

		/* Formulas: the strip's raised fill and divider, and the hover edge, follow any retone. */
		--_raised: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
		--_divider: color-mix(in oklab, var(--_hairline) 35%, transparent);
		--_edge-hover: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
		--_h: 2.25rem;
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
	}
	@media (pointer: coarse) {
		.slug-field__control {
			--_h: 2.75rem;
		}
	}
	.slug-field[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
	}

	/* The edge and halo follow the input inside, so their transition lives with these rules. */
	.slug-field__control {
		transition-property: border-color, box-shadow;
		transition-duration: 150ms;
		transition-timing-function: var(--_ease);
	}
	@media (hover: hover) {
		/* Only the input row takes a press, so only it darkens the edge; the URL strip stays selectable text. */
		.slug-field__control:has(.slug-field__row:hover):not(:has(input:disabled)) {
			--_edge: var(--_edge-hover);
		}
		.slug-field[data-invalid] .slug-field__control:has(.slug-field__row:hover) {
			--_edge: var(--_danger);
		}
	}
	/* Focus: a two-pixel edge (border plus one ring) around both tiers, with a soft halo. */
	.slug-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);
	}
	.slug-field__control:has(input:disabled) {
		opacity: 0.55;
	}

	/* Small text buttons reach 44 px on touch screens through an invisible hit area. */
	@media (pointer: coarse) {
		.slug-field__action::after,
		.slug-field__suggest::after {
			content: '';
			position: absolute;
			inset: -0.75rem -0.5rem;
		}
	}

	/* Pending: the spinner turns at a constant rate (an eased turn reads as stuttering); under reduced motion it holds still and the text carries it. */
	.slug-field__spinner {
		animation: slug-field-spin 0.8s linear infinite;
	}
	@media (prefers-reduced-motion: reduce) {
		.slug-field__spinner {
			animation: none;
		}
	}
	@keyframes slug-field-spin {
		to {
			transform: rotate(1turn);
		}
	}

	@media (forced-colors: active) {
		.slug-field__control:has(input:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>

Pass a label, the title as source and a basePath, and bind value: the slug follows the title until the person edits it, and the strip under the input shows the finished URL. Add checkAvailability to ask your backend whether a slug is free. The check is advisory and racy, so your server must still enforce uniqueness on save. It does not reserve the slug, create redirects when a slug changes, or transliterate non-Latin scripts.

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

Limitations

  • The availability check is advisory: two people can pass it with the same slug at once. Enforce uniqueness on save and pass the save's failure back through error.
  • The check also runs for the slug the record already has, once on mount. Have your endpoint report the record's own current slug as available.
  • The default slugify drops letters with no Latin decomposition (Cyrillic, Greek, Arabic, Hebrew, CJK). Pass slugify with a transliteration library for those titles.
  • It does not reserve the slug or create a redirect when a published slug changes; both belong to your backend.
  • Derivation needs client JavaScript. Before hydration the server-rendered input already holds the derived slug and submits it with the form.
  • Lowering maxLength does not rewrite a slug already in the field: the URL strip and the check use the shortened slug at once, and the field's value follows on its next blur.
  • The status line holds one line. A consumer message or error long enough to wrap grows it; keep messages short, as the defaults are.

Example

Svelte
<script lang="ts">
	import SlugField, {
		type SlugAvailabilityCheck
	} from '$lib/components/slug-field-01/SlugField.svelte';

	let title = $state('Roadmaps that survive Monday');
	let slug = $state('');

	const checkAvailability: SlugAvailabilityCheck = async (candidate, signal) => {
		const res = await fetch(`/api/slugs/${encodeURIComponent(candidate)}`, { signal });
		if (!res.ok) throw new Error('Check failed');
		return res.json();
	};
</script>

<form method="POST" class="grid max-w-md gap-5">
	<label class="grid gap-2 text-sm font-medium">
		Title
		<input name="title" bind:value={title} class="h-9 rounded-lg border px-3" />
	</label>
	<SlugField
		label="Post URL"
		name="slug"
		source={title}
		basePath="halcyon.app/blog/"
		bind:value={slug}
		{checkAvailability}
	/>
</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.

Slug field#

A path-friendly identifier for a post, product, collection or workspace. It follows a source such as the title until the person edits it, shows the finished URL inside its own border, and can ask your backend whether the slug is free.

Svelte
<SlugField
	label="Post URL"
	name="slug"
	source={title}
	basePath="halcyon.app/blog/"
	bind:value={slug}
	{checkAvailability}
/>

Following the source#

While the slug follows source, value is written with slugify(source) cut to maxLength, and the label row says "Follows the title". The first keystroke in the slug stops that, and the marker becomes a "Regenerate from title" button that makes it follow again. A value you pass in that differs from the derivation counts as an edit, so an existing record keeps its slug.

Name the source in messages.synced and messages.regenerate when it is not a title.

Normalising#

What the person types is left alone until the field loses focus, then normalised with the same function, so the caret never jumps mid-word. If that changed anything, the hint line under the field says "Adjusted to a–z, 0–9 and hyphens." until the next edit. Slugs are cut at the last hyphen before maxLength and never end in a hyphen.

The default slugify handles Latin text with diacritics. Titles in other scripts produce an empty slug; pass a slugify built on a transliteration library if your content needs it.

The availability check is advisory#

TypeScript
const checkAvailability: SlugAvailabilityCheck = async (slug, signal) => {
	const res = await fetch(`/api/slugs/${encodeURIComponent(slug)}`, { signal });
	if (!res.ok) throw new Error('Check failed');
	return res.json(); // { available: boolean; message?: string; suggestion?: string }
};
  • It runs once on mount, then after checkDelay (400 ms) of quiet. Each change aborts the request in flight through signal, and a reply for an old slug is never applied.
  • Return suggestion with a taken slug and the status line offers "Use spring-sale-2".
  • A rejection shows "Couldn't check availability", and the person can still save.
  • Report the record's own current slug as available, or an existing post will read as taken.
  • Two people can pass the check with the same slug at once. Your server must enforce uniqueness on save; pass the failure back through error.

What it does not do#

It does not reserve a slug, create redirects when a published slug changes, or decide whether a slug may change after publishing. Those are your backend's rules.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label above the field.
valuestringNoNoneBindable slug. While the slug follows source the field writes the derived slug into it; a different value set from outside, or typed, stops following. Typed text is normalised on blur.
sourcestringNoNoneText to derive from, such as the title. An untouched empty field starts following a source that arrives later. Editing the slug stops derivation; the regenerate button resumes it. Omit for a field with no source and no button.
basePathstringNoNoneAddress shown before the slug in the URL strip, such as 'halcyon.app/blog/'. Omit for no strip.
maxLengthnumberNo60Longest slug allowed. Derived and normalised slugs are cut at a hyphen boundary below it.
checkAvailability(slug: string, signal: AbortSignal) => Promise<{ available: boolean; message?: string; suggestion?: string }>NoNoneAdvisory uniqueness check. Runs once on mount, then after checkDelay of quiet; the signal aborts when the slug changes and a reply for an old slug is ignored. A rejection shows the check-failed status.
checkDelaynumberNo400Milliseconds of quiet after a change before the check runs.
slugify(input: string) => stringNoNoneReplaces the default NFKD, strip-diacritics, lowercase and hyphenate function. It is applied once to typed or derived text, never to its own output, and maxLength still applies to its result.
errorstring | nullNonullError from your code, such as a failed save. Replaces the status line, turns the edge to the danger colour and sets aria-invalid.
hintstringNoNoneHelp text under the field. Defaults to 'a–z, 0–9 and hyphens, up to {maxLength} characters.' After a blur changes what was typed, this line shows messages.normalised until the next edit.
messagesPartial<SlugFieldMessages>NoNoneStatus, marker and button text: synced, regenerate, checking, available, taken, failed, normalised, useSuggestion and useSuggestionLabel ({slug} is replaced), errorPrefix. For other languages, or a source that is not a title.
disabledbooleanNofalseDisables the input, the regenerate button and the suggestion button.
idstringNoNoneInput id. Defaults to one generated per instance; the hint, preview and status ids are always generated.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

Change the copy through label, hint and messages, and retone the field through nine --slug-field-* CSS variables. The strip's fill, its divider and the hover edge are mixed from those, so they follow any retone.

  • Accent: --slug-field-accent colours the focus edge, its halo and the focus outline on the two buttons. Keep it at 3:1 against the page.
  • Meaning: --slug-field-success colours Available (green-700 by default, 5:1 on white) and --slug-field-danger colours Taken and the error (red-700). Each verdict also has an icon and text.
  • Neutrals: --slug-field-ink is the label, slug and the slug in the strip; --slug-field-muted the hint, base path and secondary status; --slug-field-placeholder the placeholder; --slug-field-hairline the resting edge (keep it at 3:1, it is the only thing marking an empty field); --slug-field-surface the input fill.
  • Worked retone for a #09090b band: --slug-field-accent: #fafafa; --slug-field-ink: #fafafa; --slug-field-muted: #a1a1aa; --slug-field-placeholder: #a1a1aa; --slug-field-hairline: rgb(255 255 255 / 0.35); --slug-field-surface: #18181b; --slug-field-success: #4ade80; --slug-field-danger: #f87171. Add color-scheme: dark on the band.
  • Cream page such as a bakery's #fbf7f0: leave the hairline alone (alpha black tints with the page) and set --slug-field-surface: #fffdf9.
  • Source: pass a title, product name or collection name as source, and set messages.synced and messages.regenerate to name it ('Follows the collection name', 'Regenerate from name').
  • Slugify: pass slugify to use your backend's own rules (underscores, a transliteration library). The component still cuts the result to maxLength.
  • Check: debounce with checkDelay, return message to word the verdict ('Used by another collection.') and suggestion to offer a free slug. Report the record's own slug as available.
  • Layout: the field fills its container. Stack it 20 to 24 px from neighbouring fields and keep it in a form column of 24 to 32rem so the URL strip reads on one line.

Public CSS variables

VariableToken
--slug-field-accentaccent
--slug-field-inkink
--slug-field-mutedmuted
--slug-field-placeholderplaceholder
--slug-field-hairlinehairline
--slug-field-surfacesurface
--slug-field-successsuccess
--slug-field-dangerdanger

Accessibility#

On this page
  • A native <label for> names the input. aria-describedby lists the URL strip, the hint (which becomes the normalisation notice after a blur changed the slug) and the status line or, while one is set, the error, each only when present.
  • The status line is a polite role=status region that holds its text until a check is sent, so it speaks once per settled check rather than on every keystroke. Every verdict has text and an icon; colour is never the only signal.
  • The regenerate and suggestion buttons have visible labels; the suggestion's accessible name is 'Use suggested slug: spring-sale-2'. Either moves focus to the input, because the button that was pressed goes away.
  • The input and URL strip are set left to right in right-to-left pages, because a slug and a URL read that way.
  • The error is not a live region. Move focus or announce a summary when a save fails, as your form decides.
  • Focus draws a two-pixel accent edge around the whole field, URL strip included; in forced-colours mode it becomes a Highlight outline. The input and both buttons reach 44 px on touch screens.

Known limitations

  • The default slugify drops non-Latin scripts rather than transliterating them.

Release details#

On this page
Integration
  • Local interaction
  • Requires client-side JavaScript to be interactive
  • 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.