Skip to content
Palette Neutral
Download ZIP

Neutral palette · 5.4 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_site_shell_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_site_shell_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-a64fa0314ecfd1d5f9929f88a6ac1b8109bea97de2b8748a030d1a4c80f3979d
SiteShell.svelte Svelte · 6.4 KB Raw
<!--
	The outer frame of a page: skip link, optional announcement banner, header, main and
	footer in a column at least one viewport tall, so a short page still ends on its footer.
	The shell draws no navigation. The header, banner and footer are the consumer's snippets.
-->
<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** Page content, rendered inside the main landmark. */
		children: Snippet;
		/** Site header, rendered inside the shell's header element. */
		header?: Snippet;
		/** Site footer, rendered inside the shell's footer element. */
		footer?: Snippet;
		/** Announcement bar above the header. It scrolls away; only the header sticks. */
		banner?: Snippet;
		/** Accessible name of the banner region. */
		bannerLabel?: string;
		/** Keeps the header at the top while the page scrolls and clears it from anchor jumps. */
		stickyHeader?: boolean;
		/** Id of the main element and target of the skip link. */
		mainId?: string;
		/** Text of the skip link. */
		skipLabel?: string;
		/**
		 * Wrap the header and footer snippets in header and footer elements. Set false when the
		 * snippets render their own, so the page keeps one banner and one contentinfo landmark.
		 */
		landmarks?: boolean;
	}

	let {
		children,
		header,
		footer,
		banner,
		bannerLabel = 'Announcement',
		stickyHeader = false,
		mainId = 'main',
		skipLabel = 'Skip to content',
		landmarks = true
	}: Props = $props();

	let headerElement = $state<HTMLElement>();
	let mainElement = $state<HTMLElement>();

	const sticky = $derived(stickyHeader && !!header);
	/* Blank strings would leave an unnamed link, an unnamed region or a skip link to nowhere. */
	const targetId = $derived(mainId.trim() || 'main');
	const skipText = $derived(skipLabel.trim() || 'Skip to content');
	const regionLabel = $derived(bannerLabel.trim() || 'Announcement');

	/*
	 * The document's scroll padding (see the html rule below) starts from an estimate that holds
	 * without JavaScript. Once the page is running, the header's real height replaces it and
	 * follows every resize, such as navigation that wraps onto a second row.
	 */
	$effect(() => {
		if (!sticky || !headerElement) return;
		const root = document.documentElement;
		const target = headerElement;
		const measure = () => {
			const height = Math.ceil(target.getBoundingClientRect().height);
			root.style.setProperty('--_site-shell-header', `${height}px`);
		};
		// Measured now as well, so a fragment jump right after mount already clears the header.
		measure();
		const observer = new ResizeObserver(measure);
		observer.observe(target, { box: 'border-box' });
		return () => {
			observer.disconnect();
			root.style.removeProperty('--_site-shell-header');
		};
	});

	/*
	 * The link's own fragment navigation moves focus to main without JavaScript. Focusing it here
	 * as well keeps the skip working under routers that cancel in-page link clicks.
	 */
	function skip() {
		mainElement?.focus();
	}
</script>

<div
	class="site-shell flex min-h-[var(--_min-height)] flex-col bg-[var(--_surface)] text-[var(--_ink)] print:min-h-0"
	data-sticky-header={sticky ? '' : undefined}
>
	<!--
		First focusable element on the page. It waits above the viewport and slides in on focus,
		as the one floating surface in the shell: popover elevation, accent focus ring. The
		transparent border becomes its outline in forced-colours mode, where shadows disappear.
	-->
	<a
		href="#{encodeURIComponent(targetId)}"
		onclick={skip}
		class="fixed start-3 top-3 z-50 inline-flex min-h-11 max-w-[calc(100%-1.5rem)] -translate-y-[calc(100%+3rem)] items-center rounded-lg border border-transparent bg-[var(--_surface)] px-4 py-3 text-sm leading-5 font-medium break-words text-[var(--_ink)] shadow-[0_0_0_1px_var(--_hairline),0_4px_6px_-1px_rgb(0_0_0/0.07),0_10px_15px_-3px_rgb(0_0_0/0.05)] transition-transform duration-150 ease-[cubic-bezier(.4,0,1,1)] focus:translate-y-0 focus:duration-200 focus:ease-[cubic-bezier(.16,1,.3,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] motion-reduce:transition-none print:hidden"
	>
		{skipText}
	</a>

	{#if banner}
		<section aria-label={regionLabel} class="print:hidden">
			{@render banner()}
		</section>
	{/if}

	{#if header}
		<svelte:element
			this={landmarks ? 'header' : 'div'}
			bind:this={headerElement}
			class={[sticky && 'site-shell__sticky sticky top-0 z-40 bg-[var(--_surface)] print:static']}
		>
			{@render header()}
		</svelte:element>
	{/if}

	<!-- tabindex -1 lets the skip link move focus here; main is never a tab stop itself. -->
	<main bind:this={mainElement} id={targetId} tabindex="-1" class="grow outline-none">
		{@render children()}
	</main>

	{#if footer}
		<svelte:element this={landmarks ? 'footer' : 'div'} class="text-[var(--_muted)]">
			{@render footer()}
		</svelte:element>
	{/if}
</div>

<style>
	/* Public tokens: set --site-shell-* on the shell or any ancestor to retone it. */
	.site-shell {
		--_surface: var(--site-shell-surface, #ffffff);
		--_ink: var(--site-shell-ink, #18181b);
		--_muted: var(--site-shell-muted, #52525b);
		--_hairline: var(--site-shell-hairline, rgb(0 0 0 / 0.08));
		--_accent: var(--site-shell-accent, #18181b);
		--_min-height: var(--site-shell-min-height, 100dvh);
	}

	/*
	 * With a sticky header, anchor jumps, the skip link and keyboard focus scroll their target
	 * clear of it. Scroll padding belongs to the scrolling element, so this rule sits on html and
	 * applies only while a sticky shell is on the page. Set these two tokens on :root.
	 */
	:global(html:has(.site-shell[data-sticky-header])) {
		--_site-shell-estimate: var(--site-shell-header-height, 4rem);
		--_site-shell-gap: var(--site-shell-scroll-gap, 1rem);
		scroll-padding-top: calc(
			var(--_site-shell-header, var(--_site-shell-estimate)) + var(--_site-shell-gap)
		);
	}

	/*
	 * Once the page has scrolled, the stuck header lifts off the content with a faint shadow lit
	 * from above. Scroll-driven, so it needs no listener; browsers without scroll timelines keep
	 * the header flat, and the header's own bottom edge still marks it.
	 */
	@supports (animation-timeline: scroll()) {
		.site-shell__sticky {
			animation: site-shell-lift linear both;
			animation-timeline: scroll(root block);
			animation-range: 0 2rem;
		}
	}

	@keyframes site-shell-lift {
		from {
			box-shadow:
				0 1px 2px rgb(0 0 0 / 0),
				0 8px 24px -12px rgb(0 0 0 / 0);
		}
		to {
			box-shadow:
				0 1px 2px rgb(0 0 0 / 0.04),
				0 8px 24px -12px rgb(0 0 0 / 0.12);
		}
	}
</style>

Layout only: it renders your header, banner, footer and page into one landmark structure and draws no navigation, menus, theme switch or page title. Put it in your root layout once per page. Needs no JavaScript; with JavaScript the sticky header's real height replaces the scroll-padding estimate.

Suggested location
src/lib/components/site-shell-01
Required props
children

Limitations

  • One shell per page. The main element takes mainId as its id, so two shells on one page would share it.
  • Renders no navigation, menu, theme switch or page title; the header, banner and footer are your snippets.
  • With stickyHeader, only the header sticks; the banner scrolls away above it. The shell sets scroll-padding-top on html while it is on the page, which replaces any scroll padding of your own there.
  • Until JavaScript runs, the scroll padding uses --site-shell-header-height (4rem by default). Set it on :root if your header is taller.
  • The shadow that appears under a stuck header uses scroll-driven animations; browsers without them keep the header flat.
  • Light by default. The tokens retone it for a dark page, but no dark mode is declared or selected automatically.

Example

Svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import SiteShell from '$lib/components/site-shell-01/SiteShell.svelte';

	let { children } = $props();
</script>

<SiteShell stickyHeader>
	{#snippet header()}
		<div class="mx-auto flex h-16 max-w-6xl items-center px-4">
			<a href="/" class="font-semibold">Halcyon</a>
		</div>
	{/snippet}

	{@render children()}

	{#snippet footer()}
		<p class="mx-auto max-w-6xl px-4 py-6 text-sm">© 2026 Halcyon</p>
	{/snippet}
</SiteShell>

Site shell#

Where it goes#

Use the shell once, in your root +layout.svelte, so every page shares the same skip link, header, main and footer. Pages render into children. The shell adds no width or padding of its own: put your container classes inside each snippet and page, so a full-bleed banner or a dark footer band can still run edge to edge.

The shell is a column at least --site-shell-min-height tall (100dvh by default), and main takes whatever height the header and footer leave. On a short page, such as a 404 or a sign-in form, the spare height goes to main and the footer rests on the bottom of the viewport. On a long page nothing changes.

100dvh follows the mobile address bar as it hides and shows. If you would rather the footer never moved, set:

CSS
:root {
	--site-shell-min-height: 100svh;
}

Sticky header and anchors#

stickyHeader pins the header region, not your snippet, because a sticky element only sticks within its parent. It then sets scroll-padding-top on html, so these all land below the header:

  • in-page anchors (<a href="#pricing">) and links from other pages to /page#pricing
  • the skip link's jump to main
  • keyboard focus moving to a link or field near the top of the viewport

Before JavaScript runs, the padding uses --site-shell-header-height (4rem) plus --site-shell-scroll-gap (1rem). Once the page is running the shell measures the header as it mounts, then with a ResizeObserver, and uses its real height instead, including when navigation wraps onto a second row. Changing the padding does not scroll the page again, so if your header changes height after the browser has already jumped to an anchor, scroll to it again yourself. Set both variables on :root, because the padding lives on html:

CSS
:root {
	--site-shell-header-height: 4.5rem;
	--site-shell-scroll-gap: 1.5rem;
}

Only the header sticks. The banner scrolls away above it, which keeps an announcement from permanently taking viewport height on a phone. Avoid overflow: hidden or overflow-x: hidden on body or any ancestor of the shell: it turns off sticky positioning.

Landmarks#

The shell renders <header>, <main> and <footer>, so your snippets should render their contents, not another <header> or <footer>. If your header component renders its own <header> (site-header-01 does), pass landmarks={false} and the shell wraps the snippets in plain divs instead. The switch covers both regions, so your footer snippet then has to render its own <footer> as well:

Svelte
<SiteShell stickyHeader landmarks={false}>
	{#snippet header()}
		<SiteHeader {brand} {items} {currentPath} />
	{/snippet}
	{@render children()}
	{#snippet footer()}
		<SiteFooter />
	{/snippet}
</SiteShell>

Use the shell's stickyHeader rather than the header's own sticky prop in that case: inside the shell, the header component's parent is the header region, so its own sticky positioning would have nowhere to go.

The skip link waits above the viewport and slides in when it receives focus, which in practice means the first press of Tab. It links to #main (or your mainId), and main has tabindex="-1", so both the browser's own fragment navigation and the shell's click handler move focus there. Translate skipLabel along with the rest of the page.

Print#

In print the skip link and banner are hidden, the header stops sticking, and the minimum height is dropped, so a short page does not print a blank sheet before its footer.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
childrenSnippetYesNonePage content, rendered inside the main landmark.
headerSnippetNoNoneSite header. Rendered inside a header element (or a div with landmarks={false}); omitted, no header region renders.
bannerSnippetNoNoneAnnouncement bar above the header, in a labelled region. It scrolls away with the page.
bannerLabelstringNo'Announcement'Accessible name of the banner region. A blank value falls back to the default.
stickyHeaderbooleanNofalseKeeps the header at the top of the viewport and sets scroll-padding-top on html to its height, so anchors and focused elements land below it.
mainIdstringNo'main'Id of the main element and target of the skip link. A blank value falls back to 'main'.
skipLabelstringNo'Skip to content'Text of the skip link. A blank value falls back to the default. Long labels wrap.
landmarksbooleanNotrueWraps the header and footer snippets in header and footer elements. Set false when your snippets render their own; it switches both wrappers to divs, so each snippet you pass must then bring its own header or footer element.

Customization#

On this page

Pass your own header, footer and banner as snippets, retone the frame through five colour variables, and set its minimum height and sticky-header offset through three layout variables.

  • Content: everything visible except the skip link comes from your snippets. The shell adds no padding or width; put your own container (for example mx-auto max-w-6xl px-4) inside each snippet and page.
  • Colours: --site-shell-surface is the page background and the stuck header's fill; --site-shell-ink is the inherited text colour; --site-shell-muted is the footer's inherited text colour; --site-shell-hairline outlines the revealed skip link; --site-shell-accent is its focus ring.
  • Dark page retone: surface #09090b, ink #fafafa, muted #a1a1aa, hairline rgb(255 255 255 / 0.12), accent #fafafa (all --site-shell-*). Your snippets set their own colours.
  • Height: --site-shell-min-height defaults to 100dvh, the viewport's current height. Set it to 100svh if the footer should never move as a mobile address bar hides, or to a fixed length inside a frame such as a storybook.
  • Sticky offset: set --site-shell-header-height on :root to your header's height, used before JavaScript measures it, and --site-shell-scroll-gap (1rem) for the space left between the header and an anchored heading.
  • Composing with site-header-01: pass it as the header snippet with landmarks={false}, because it renders its own header element, and use the shell's stickyHeader rather than the header's own sticky prop.
  • Skip link: set skipLabel in your site's language. It targets mainId; change mainId if your pages already use id="main" for something else.

Public CSS variables

VariableToken
--site-shell-surfacesurface
--site-shell-inkink
--site-shell-mutedmuted
--site-shell-hairlinehairline
--site-shell-accentaccent
--site-shell-min-heightminHeight
--site-shell-header-heightheaderHeight
--site-shell-scroll-gapscrollGap

Accessibility#

On this page
  • The skip link is the first focusable element. It sits above the viewport until focused, then slides in at the top start corner with a two-pixel accent focus ring, and it is at least 44 px tall.
  • Activating the skip link moves focus to main, which has tabindex="-1" so it can take focus without becoming a tab stop, and shows no outline of its own.
  • The shell provides the header (banner), main and footer (contentinfo) landmarks. Your snippets should not render another header or footer element; if they do, set landmarks={false}, which switches both wrappers to divs, so the footer snippet must then render its own footer element too.
  • The banner snippet sits in a section named by bannerLabel, so it is announced as a region and never as a second banner landmark.
  • With stickyHeader, scroll-padding-top on html keeps anchored headings, the skip link target and keyboard focus below the header (WCAG 2.2 SC 2.4.11).
  • In forced-colours mode the revealed skip link keeps an outline from its transparent border.
  • Under prefers-reduced-motion the skip link appears without sliding.
  • The skip link text is ink (#18181b) on white, about 17.7:1; muted footer text (#52525b) on white is 7.7:1.
  • Your responsibilities: a nav element with an accessible name inside the header, a single h1 inside each page, and a translated skipLabel and bannerLabel.

Known limitations

  • Contrast is checked for the neutral defaults only; recheck any retoned surface, ink, muted or accent (4.5:1 for text, 3:1 for the focus ring).
  • The skip link cannot know whether main has content; on a page whose content starts with its own skip target, change mainId.

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 30 September 2026

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