Skip to content
Download ZIP

Blue palette · 9.5 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_primary_nav_01 · version 1.0.0 · Blue palette
Using the PageSugar MCP server, fetch component cmp_primary_nav_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Blue
Version
1.0.0
Digest
Full digest
sha256-57d787fff7f2f45e0fe98302e93c089211a2a2ba02e3469348f7eafadd35aee1

This component needs all 2 files. Download the ZIP

PrimaryNav.svelte Svelte · 15.4 KB Raw
<script lang="ts">
	import { onMount, tick, untrack } from 'svelte';
	import {
		currentIndex,
		currentState,
		type NavIndicator,
		type NavLink,
		type NavMatch
	} from './types';

	interface Props {
		/** Top-level destinations in priority order; trailing items are the first to move into More. */
		items: NavLink[];
		/** Current URL pathname, for example page.url.pathname. */
		currentPath?: string;
		/** `prefix` also marks a section current for any page below it. */
		match?: NavMatch;
		/** How the current page is marked in the row. */
		indicator?: NavIndicator;
		/** Collect the items that do not fit into a More disclosure. Off, the row wraps. */
		overflow?: boolean;
		/** Text of the overflow button. */
		moreLabel?: string;
		/** Hidden suffix on the overflow button while the current page is inside it. */
		moreCurrentHint?: string;
		/** Accessible name of the nav landmark. */
		label?: string;
	}

	let {
		items: givenItems,
		currentPath,
		match = 'exact',
		indicator = 'underline',
		overflow = true,
		moreLabel = 'More',
		moreCurrentHint = 'includes the current page',
		label = 'Main'
	}: Props = $props();

	const uid = $props.id();
	const panelId = `${uid}-more`;

	/* A link with no text has no accessible name, so an item with a blank label is not rendered. */
	const items = $derived(givenItems.filter((item) => item.label.trim() !== ''));
	const current = $derived(currentIndex(items, currentPath, match));

	/*
	 * How many items fit on the row, or null before the first measurement. The server and the
	 * first client frame render every item in a wrapping row, so nothing is hidden before
	 * hydration; the measurement then collects the trailing items that do not fit into More.
	 */
	let fitCount = $state<number | null>(null);
	const count = $derived(
		overflow && fitCount !== null ? Math.min(fitCount, items.length) : items.length
	);
	const inline = $derived(items.slice(0, count));
	const collected = $derived(items.slice(count));
	const currentInMore = $derived(current >= count);

	let hydrated = $state(false);
	let open = $state(false);
	let placement = $state<'end' | 'start'>('end');

	let list = $state<HTMLUListElement>();
	let ruler = $state<HTMLDivElement>();
	let moreItem = $state<HTMLLIElement>();
	let moreButton = $state<HTMLButtonElement>();
	let panel = $state<HTMLDivElement>();

	onMount(() => {
		hydrated = true;
	});

	/*
	 * Widths come from the ruler, an invisible copy of every label set at the weight it renders
	 * at, never from the row itself. The row's width does not depend on how many items it holds,
	 * so the count is a pure function of two measurements and cannot oscillate at a boundary.
	 */
	function measure() {
		if (!list || !ruler) return;
		const available = list.clientWidth;
		const marks = Array.from(ruler.children) as HTMLElement[];
		const widths = marks.slice(0, items.length).map((mark) => mark.getBoundingClientRect().width);
		const moreWidth = marks[items.length]?.getBoundingClientRect().width ?? 0;
		const gap = parseFloat(getComputedStyle(list).columnGap) || 0;
		const all =
			widths.reduce((sum, width) => sum + width, 0) + gap * Math.max(0, widths.length - 1);
		let next = widths.length;
		if (all > available + 0.5) {
			let used = 0;
			next = 0;
			for (const width of widths) {
				const withItem = used + (next > 0 ? gap : 0) + width;
				if (withItem + gap + moreWidth > available + 0.5) break;
				used = withItem;
				next += 1;
			}
		}
		if (next === fitCount) return;
		// Focus follows its destination between the row and the panel, so a resize never drops it.
		const active = document.activeElement;
		const focusedMore = active === moreButton;
		const focusedItem =
			active instanceof HTMLElement && list.contains(active) && active.dataset.itemIndex
				? Number(active.dataset.itemIndex)
				: -1;
		const before = count;
		fitCount = next;
		if (focusedItem === -1 && !focusedMore) return;
		tick().then(() => {
			if (!list || list.contains(document.activeElement)) return;
			// A collected link lands on More; More itself, gone, hands focus to the first item it held.
			const target =
				focusedItem !== -1 && focusedItem < next ? focusedItem : focusedMore ? before : -1;
			const link =
				target === -1
					? null
					: list.querySelector<HTMLElement>(`:scope > li > a[data-item-index="${target}"]`);
			(link ?? moreButton)?.focus();
		});
	}

	$effect(() => {
		// The current item is set heavier, so the fit changes with it even when the ruler's total does not.
		void current;
		void items;
		if (!overflow || !list || !ruler) return;
		// Measure on the next frame: changing the row inside the observer's own callback loops it.
		let frame = 0;
		const schedule = () => {
			cancelAnimationFrame(frame);
			frame = requestAnimationFrame(measure);
		};
		// The ruler resizes when a web font lands or a label changes; the list when its column does.
		const observer = new ResizeObserver(schedule);
		observer.observe(list);
		observer.observe(ruler);
		untrack(measure);
		return () => {
			cancelAnimationFrame(frame);
			observer.disconnect();
		};
	});

	/* Nothing left to collect, nothing to show. */
	$effect(() => {
		if (collected.length === 0) open = false;
	});

	/*
	 * The panel hangs from More's end edge. When that would cross the viewport's gutter it hangs
	 * from the start edge instead, and when both would, from whichever crosses less.
	 */
	function place() {
		if (!panel || !moreItem) return;
		const gutter = 8;
		const viewport = document.documentElement.clientWidth;
		const anchor = moreItem.getBoundingClientRect();
		const width = panel.offsetWidth;
		const rtl = getComputedStyle(panel).direction === 'rtl';
		// Left edges of the panel for each placement, in physical pixels.
		const endLeft = rtl ? anchor.left : anchor.right - width;
		const startLeft = rtl ? anchor.right - width : anchor.left;
		const spill = (left: number) =>
			Math.max(0, gutter - left) + Math.max(0, left + width - (viewport - gutter));
		placement = spill(endLeft) <= spill(startLeft) ? 'end' : 'start';
	}

	$effect(() => {
		if (open && panel) untrack(place);
		else placement = 'end';
	});

	const panelLinks = () =>
		panel ? Array.from(panel.querySelectorAll<HTMLAnchorElement>('a[href]')) : [];

	function close(returnFocus: boolean) {
		open = false;
		if (returnFocus) moreButton?.focus();
	}

	async function onButtonKeydown(event: KeyboardEvent) {
		if (event.key !== 'ArrowDown') return;
		event.preventDefault();
		open = true;
		await tick();
		panelLinks()[0]?.focus();
	}

	/* Escape from anywhere, and the optional arrow keys of the APG disclosure navigation pattern. */
	function onWindowKeydown(event: KeyboardEvent) {
		if (event.key === 'Escape') {
			event.preventDefault();
			close(true);
			return;
		}
		if (!(event.target instanceof Node) || !panel?.contains(event.target)) return;
		const links = panelLinks();
		const index = links.indexOf(document.activeElement as HTMLAnchorElement);
		let next: HTMLAnchorElement | undefined;
		if (event.key === 'ArrowDown') next = links[Math.min(index + 1, links.length - 1)];
		else if (event.key === 'ArrowUp') next = index <= 0 ? undefined : links[index - 1];
		else if (event.key === 'Home') next = links[0];
		else if (event.key === 'End') next = links[links.length - 1];
		else return;
		event.preventDefault();
		if (next) next.focus();
		else moreButton?.focus();
	}

	/* Focus or a press anywhere outside the More item closes it. */
	function onOutside(event: Event) {
		if (event.target instanceof Node && moreItem && !moreItem.contains(event.target)) open = false;
	}

	/*
	 * Spacing set: 4, 8 and 12 px. Rows are 48 px and links 32 px, so a focus ring clears the current
	 * bar under them; under a coarse pointer, 56 px rows hold 44 px links for the same clearance.
	 */
	const focusClass =
		'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]';
	const itemClass = $derived([
		'inline-flex min-h-8 items-center px-3 text-sm whitespace-nowrap transition-[color,background-color,box-shadow] duration-150 ease-[cubic-bezier(.2,0,0,1)] active:duration-[80ms] pointer-coarse:min-h-11',
		indicator === 'pill' ? 'rounded-full' : 'rounded-lg',
		focusClass
	]);
	const restClass =
		'font-medium text-[var(--_muted)] hover:bg-[var(--_fill)] hover:text-[var(--_ink)] active:bg-[var(--_fill-pressed)]';
	const currentClass = $derived(
		indicator === 'pill'
			? // Hover and press darken the pill from above; with a light label on the accent, contrast rises.
				'bg-[var(--_accent)] font-semibold text-[var(--_on-accent)] hover:shadow-[inset_0_0_0_999px_rgb(0_0_0/0.1)] active:shadow-[inset_0_0_0_999px_rgb(0_0_0/0.18)]'
			: 'font-semibold text-[var(--_ink)] hover:bg-[var(--_fill)] active:bg-[var(--_fill-pressed)]'
	);
	/* The current page in the underline row: a 2 px accent bar resting on the row's hairline. A border, so forced colours keep it. */
	const barClass =
		"after:absolute after:inset-x-3 after:-bottom-px after:border-b-2 after:border-[var(--_accent)] after:content-['']";
</script>

<!--
	Every label reserves its semibold width, so marking a different page current never moves the
	labels after it, and the ruler can measure each one at a single weight.
-->
{#snippet labelText(text: string)}
	<span
		data-label={text}
		class="grid justify-items-start before:invisible before:col-start-1 before:row-start-1 before:h-0 before:font-semibold before:content-[attr(data-label)]"
		><span class="col-start-1 row-start-1">{text}</span></span
	>
{/snippet}

<svelte:window onkeydown={open ? onWindowKeydown : undefined} onresize={open ? place : undefined} />
<svelte:document
	onfocusin={open ? onOutside : undefined}
	onpointerdown={open ? onOutside : undefined}
/>

{#if items.length > 0}
	<nav
		aria-label={label}
		class={[
			'primary-nav relative min-w-0',
			indicator === 'underline' && 'border-b border-[var(--_hairline)]'
		]}
	>
		<ul
			bind:this={list}
			role="list"
			class={[
				'flex min-h-12 gap-x-1 pointer-coarse:min-h-14',
				overflow && fitCount !== null ? 'flex-nowrap' : 'flex-wrap',
				// The row pulls out by the link padding, so label text shares the header's left edge.
				'-mx-3',
				indicator === 'underline' ? 'items-stretch' : 'items-center'
			]}
		>
			{#each inline as item, index (index)}
				{@const state = currentState(items, index, current, currentPath)}
				<li class={['relative flex items-center', state && indicator === 'underline' && barClass]}>
					<a
						href={item.href}
						aria-current={state}
						data-item-index={index}
						class={[itemClass, state ? currentClass : restClass]}
					>
						{@render labelText(item.label)}
					</a>
				</li>
			{/each}

			{#if collected.length > 0}
				<li
					bind:this={moreItem}
					class={[
						'relative flex items-center',
						currentInMore && indicator === 'underline' && barClass
					]}
				>
					<button
						bind:this={moreButton}
						type="button"
						aria-expanded={open}
						aria-controls={panelId}
						onclick={() => (open = !open)}
						onkeydown={onButtonKeydown}
						class={[
							itemClass,
							'gap-x-1',
							currentInMore
								? currentClass
								: open
									? 'bg-[var(--_fill)] font-medium text-[var(--_ink)] active:bg-[var(--_fill-pressed)]'
									: restClass
						]}
					>
						{@render labelText(moreLabel)}
						{#if currentInMore}
							<span class="sr-only">, {moreCurrentHint}</span>
						{/if}
						<svg
							class={[
								'size-4 shrink-0 transition-transform motion-reduce:transition-none',
								// The chevron keeps time with the panel: 200 ms in, 150 ms out.
								open
									? 'rotate-180 duration-200 ease-[cubic-bezier(.16,1,.3,1)]'
									: 'duration-150 ease-[cubic-bezier(.4,0,1,1)]'
							]}
							viewBox="0 0 16 16"
							fill="none"
							aria-hidden="true"
						>
							<path
								d="M4 6l4 4 4-4"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
								stroke-linejoin="round"
							/>
						</svg>
					</button>

					<!-- The one elevated surface: 12 px radius, 8 px padding, so its 4 px rows share its centre. -->
					<div
						bind:this={panel}
						id={panelId}
						hidden={!open}
						class={[
							'absolute top-full z-30 mt-1 w-max max-w-[min(18rem,calc(100vw-2rem))] min-w-48 rounded-xl bg-[var(--_surface)] p-2 shadow-[var(--_shadow-popover)]',
							'transition-[opacity,translate,display] transition-discrete motion-reduce:transition-none',
							open
								? 'duration-200 ease-[cubic-bezier(.16,1,.3,1)] starting:-translate-y-1 starting:opacity-0'
								: '-translate-y-1 opacity-0 duration-150 ease-[cubic-bezier(.4,0,1,1)]',
							placement === 'end' ? 'end-0' : 'start-0'
						]}
					>
						<ul role="list" class="flex flex-col">
							{#each collected as item, offset (count + offset)}
								{@const state = currentState(items, count + offset, current, currentPath)}
								<li>
									<a
										href={item.href}
										aria-current={state}
										data-item-index={count + offset}
										onclick={() => close(false)}
										class={[
											'flex min-h-9 items-center rounded-sm px-3 py-2 text-sm break-words transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] active:bg-[var(--_fill-pressed)] active:duration-[80ms] pointer-coarse:min-h-11',
											state
												? 'bg-[var(--_fill-current)] font-semibold text-[var(--_ink)]'
												: 'font-medium text-[var(--_muted)] hover:bg-[var(--_fill)] hover:text-[var(--_ink)]',
											focusClass
										]}
									>
										{item.label}
									</a>
								</li>
							{/each}
						</ul>
					</div>
				</li>
			{/if}
		</ul>

		{#if overflow && hydrated}
			<!-- The ruler: every label at its rendered weight, plus More, invisible and out of the accessibility tree. -->
			<div
				bind:this={ruler}
				aria-hidden="true"
				inert
				class="pointer-events-none invisible absolute start-0 top-0 flex h-0 w-max overflow-hidden"
			>
				{#each items as item, index (index)}
					<span class="px-3 text-sm font-semibold whitespace-nowrap">{item.label}</span>
				{/each}
				<span class="inline-flex gap-x-1 px-3 text-sm font-semibold whitespace-nowrap"
					>{moreLabel}<span class="size-4 shrink-0"></span></span
				>
			</div>
		{/if}
	</nav>
{/if}

<style>
	/* Public tokens: set --primary-nav-* on the nav or any ancestor to retone it. */
	.primary-nav {
		--_accent: var(--primary-nav-accent, #2563eb);
		--_on-accent: var(--primary-nav-on-accent, #ffffff);
		--_ink: var(--primary-nav-ink, #18181b);
		--_muted: var(--primary-nav-muted, #52525b);
		--_hairline: var(--primary-nav-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--primary-nav-surface, #ffffff);
		/* Hover, current and pressed fills are mixed from the ink, so every palette gets them. */
		--_fill: color-mix(in oklab, var(--_ink) 5%, transparent);
		--_fill-current: color-mix(in oklab, var(--_ink) 8%, transparent);
		--_fill-pressed: color-mix(in oklab, var(--_ink) 11%, transparent);
		--_shadow-popover:
			0 0 0 1px rgb(0 0 0 / 0.05), 0 4px 6px -1px rgb(0 0 0 / 0.07),
			0 10px 15px -3px rgb(0 0 0 / 0.05);
	}

	:global(.dark) .primary-nav {
		--_accent: var(--primary-nav-accent, #60a5fa);
		--_on-accent: var(--primary-nav-on-accent, #09090b);
		--_ink: var(--primary-nav-ink, #fafafa);
		--_muted: var(--primary-nav-muted, #a1a1aa);
		--_hairline: var(--primary-nav-hairline, rgb(255 255 255 / 0.1));
		--_surface: var(--primary-nav-surface, #18181b);
		/* Black shadows vanish on dark; the panel is lifted by a light ring and an inset top edge. */
		--_shadow-popover: 0 0 0 1px rgb(255 255 255 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.08);
	}
</style>

Pass the destinations in priority order and the current pathname. The row measures its own width after hydration and moves trailing items into More; before that, and with overflow off, it is a plain wrapping list. It does not read the router, render nested dropdowns or switch to a mobile drawer: the header around it decides those.

Suggested location
src/lib/components/primary-nav-01
Required props
items

Limitations

  • The nav needs a width that does not depend on its content: a block element, or flex-1 with min-w-0 inside a flex row. Sized by its content, it has nothing to measure against.
  • Until the component hydrates, and whenever overflow is off, items that do not fit wrap onto another row; the first measurement then collapses them into More, so a very long list shortens by a row as the page becomes interactive.
  • Items leave the row from the end of the list only; put the destinations that must stay visible first.
  • currentPath is compared as a pathname (query, hash and a trailing slash are ignored); absolute URLs never match. With prefix matching the root / only matches itself.
  • Items with a blank label are skipped, since a link with no text has no accessible name.
  • Renders nothing when items is empty.
  • Dark colours apply inside an ancestor with the class dark; a media-query setup needs its own rule that sets the --primary-nav-* variables.

Example

Svelte
<!-- Illustrative content: replace the links with your own routes. -->
<script lang="ts">
	import { page } from '$app/state';
	import PrimaryNav from '$lib/components/primary-nav-01/PrimaryNav.svelte';
	import type { NavLink } from '$lib/components/primary-nav-01/types';

	const items: NavLink[] = [
		{ label: 'Features', href: '/features' },
		{ label: 'Pricing', href: '/pricing' },
		{ label: 'Docs', href: '/docs' },
		{ label: 'Blog', href: '/blog' }
	];
</script>

<header class="mx-auto max-w-7xl px-4 sm:px-6">
	<a href="/" class="flex min-h-14 items-center font-semibold">Example</a>
	<PrimaryNav {items} currentPath={page.url.pathname} match="prefix" />
</header>

Primary navigation#

The main row of a site's destinations, for use inside a header you build yourself. It marks the current page and, as the row narrows, moves the trailing items into a More disclosure so the row never wraps or scrolls.

Placing it in a header#

The nav measures its own width, so that width has to come from the layout, not from its links:

  • On its own row (under the brand, as in the preview), it is a block and fills the row.
  • Beside the brand in a flex row, wrap it so it takes the remaining width: <div class="min-w-0 flex-1"><PrimaryNav … /></div>.

With the underline indicator the nav draws a hairline under itself and the current page's bar sits on it. If your header already draws a bottom border, set --primary-nav-hairline: transparent and put the nav flush against that border.

Current page#

Pass the pathname (page.url.pathname in SvelteKit). Use match="prefix" when sections have pages below them: /blog is then current for /blog/planning-in-quarters, with aria-current="true" because the link is the section, not the page. The longest matching href wins, so /docs/api beats /docs for /docs/api/webhooks.

If the current item has been collected into More, the More button carries the bar (or pill) and announces moreCurrentHint, so the visitor is never left without a mark.

Before hydration#

The server renders every item in a wrapping row, and so does the first client frame. Once the component has hydrated it measures the labels (and measures again when a web font arrives or the row resizes), then collects what does not fit. Put the destinations that must always be visible first in items.

Small screens#

The component does not switch to a drawer. At phone widths most items will be in More; if you prefer a drawer, hide this nav below your breakpoint and show a drawer there instead.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
itemsNavLink[]YesNone{ label, href } destinations in priority order. Trailing items are the first to move into More.
currentPathstringNoNoneCurrent URL pathname, for example page.url.pathname. The matching item gets aria-current and the current indicator.
match'exact' | 'prefix'No'exact'prefix also marks an item current for pages below it (/blog for /blog/a-post) with aria-current="true"; an exact match always gets aria-current="page". The longest matching href wins.
indicator'underline' | 'pill'No'underline'underline: a 2 px accent bar on the row's hairline. pill: an accent-filled pill and no hairline.
overflowbooleanNotrueCollects the items that do not fit into a More disclosure. Off, the row wraps.
moreLabelstringNo'More'Text of the overflow button.
moreCurrentHintstringNo'includes the current page'Visually hidden suffix on the overflow button while the current page is inside it. Translate it with moreLabel.
labelstringNo'Main'Accessible name of the nav landmark.

Customization#

On this page

Change content through props, recolour through six --primary-nav-* CSS variables on any ancestor, and switch the current-page mark with indicator.

  • Colours: set --primary-nav-accent (the bar, the pill and the focus ring), --primary-nav-on-accent (text on the pill), --primary-nav-ink (current and hovered text), --primary-nav-muted (resting text), --primary-nav-hairline (the rule under the underline row) and --primary-nav-surface (the More panel). Hover, current and pressed fills are mixed from the ink.
  • Dark or tinted header: for a zinc-950 band set --primary-nav-ink: #fafafa, --primary-nav-muted: #a1a1aa, --primary-nav-hairline: rgb(255 255 255 / 0.1), --primary-nav-surface: #18181b and --primary-nav-accent: #fafafa with --primary-nav-on-accent: #09090b.
  • Hairline: set --primary-nav-hairline: transparent when the header draws its own bottom border, and place the nav flush with that border so the bar sits on it.
  • Priority: order items by importance; the row keeps the leading items and collects from the end.
  • Row height and density: the row is min-h-12 with min-h-8 links (min-h-14 rows and 44 px links under a coarse pointer), in PrimaryNav.svelte's itemClass and the ul.
  • Panel: width and placement are the w-max, min-w-48 and max-w utilities on the More panel; it hangs from More's end edge, or from its start edge when that keeps it further inside the viewport, and re-places itself on resize while open.
  • Overflow off: pass overflow={false} for a plain wrapping list with no measurement and no More button.

Public CSS variables

VariableToken
--primary-nav-accentaccent
--primary-nav-on-accentonAccent
--primary-nav-inkink
--primary-nav-mutedmuted
--primary-nav-hairlinehairline
--primary-nav-surfacesurface

Accessibility#

On this page
  • One nav landmark named by label; give it a distinct name if the page has other nav regions.
  • Each destination appears once in the accessibility tree: an item is either in the row or in the More panel, never both. The measuring copy of the labels is invisible, aria-hidden and inert.
  • The exact match gets aria-current="page"; with prefix matching a section containing the page gets aria-current="true". The current item is marked by a 2 px bar (or a filled pill) and a heavier weight, not by colour alone.
  • More follows the APG disclosure navigation pattern: a button with aria-expanded and aria-controls and a plain list of links, no menu roles. Enter or Space toggles it, Down Arrow opens it and enters the list, Up, Down, Home and End move between links, and Escape, focus leaving or a press outside closes it. Escape returns focus to More.
  • When the current page is inside More, the button carries the current indicator and a visually hidden moreCurrentHint suffix; translate it with moreLabel.
  • Focus is never dropped by a resize: a focused link collected into More hands focus to More, a focused panel link that returns to the row keeps focus there, and a focused More that disappears hands focus to the first item it held.
  • Links and More show a 2 px focus outline in the accent colour with a 2 px offset; links grow to 44 px under a coarse pointer.
  • IDs come from $props.id(), so two navs on one page keep distinct aria-controls targets.

Known limitations

  • Before hydration there is no More button; every item is in the wrapping list, so nothing is unreachable.
  • Hover does not open More; it opens on click and keyboard only.

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 · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.