# Site shell

> The outer frame of every page: a skip link, optional announcement bar, header, main landmark and footer, with the footer held at the bottom of short pages and anchors kept clear of a sticky header.

- ID: `cmp_site_shell_01`
- Slug: `site-shell-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: section
- Primary category: `layout`
- Detail page: https://pagesugar.com/components/site-shell-01
- Preview: https://pagesugar.com/preview/site-shell-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-a64fa0314ecfd1d5f9929f88a6ac1b8109bea97de2b8748a030d1a4c80f3979d` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/site-shell-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

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.

Required props: `children`

```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>
```

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.

## Usage guide

### 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.

#### Why the footer stays down

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 `div`s 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.

#### Skip link

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `children` | `Snippet` | yes |  | Page content, rendered inside the main landmark. |
| `header` | `Snippet` | no |  | Site header. Rendered inside a header element (or a div with landmarks={false}); omitted, no header region renders. |
| `footer` | `Snippet` | no |  | Site footer. Rendered inside a footer element (or a div with landmarks={false}), with muted text as its inherited colour. |
| `banner` | `Snippet` | no |  | Announcement bar above the header, in a labelled region. It scrolls away with the page. |
| `bannerLabel` | `string` | no | `'Announcement'` | Accessible name of the banner region. A blank value falls back to the default. |
| `stickyHeader` | `boolean` | no | `false` | Keeps 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. |
| `mainId` | `string` | no | `'main'` | Id of the main element and target of the skip link. A blank value falls back to 'main'. |
| `skipLabel` | `string` | no | `'Skip to content'` | Text of the skip link. A blank value falls back to the default. Long labels wrap. |
| `landmarks` | `boolean` | no | `true` | Wraps 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

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.

| Token | Public CSS variable |
| --- | --- |
| `surface` | `--site-shell-surface` |
| `ink` | `--site-shell-ink` |
| `muted` | `--site-shell-muted` |
| `hairline` | `--site-shell-hairline` |
| `accent` | `--site-shell-accent` |
| `minHeight` | `--site-shell-min-height` |
| `headerHeight` | `--site-shell-header-height` |
| `scrollGap` | `--site-shell-scroll-gap` |

## Accessibility

- 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.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `SiteShell.svelte`
- Suggested directory: `src/lib/components/site-shell-01`
- Files: 1
- Artifact digest: `sha256-a64fa0314ecfd1d5f9929f88a6ac1b8109bea97de2b8748a030d1a4c80f3979d`

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

#### `SiteShell.svelte`

Role: entry · 6582 bytes · SHA-256 `765e99281321cce3ec6234d146cdeab0a77ab79e12b5866b3a6b0d5768e97f21`

```svelte
<!--
	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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-a64fa0314ecfd1d5f9929f88a6ac1b8109bea97de2b8748a030d1a4c80f3979d`
- Entry: `SiteShell.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_site_shell_01/1.0.0/neutral/sha256-a64fa0314ecfd1d5f9929f88a6ac1b8109bea97de2b8748a030d1a4c80f3979d/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_site_shell_01/1.0.0/neutral/sha256-a64fa0314ecfd1d5f9929f88a6ac1b8109bea97de2b8748a030d1a4c80f3979d/bundle.zip (5535 bytes, sha256 `2725eabcd00a562de9f5cbacb958720fc394c9fa52f4a30ed87acfef3d86cd36`)

Files:

- `SiteShell.svelte` (entry, 6582 bytes): https://pagesugar.com/artifacts/cmp_site_shell_01/1.0.0/neutral/sha256-a64fa0314ecfd1d5f9929f88a6ac1b8109bea97de2b8748a030d1a4c80f3979d/source/SiteShell.svelte
