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.
cmp_site_shell_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_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
<!--
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>
Usage#
On this pageLayout 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
mainIdas 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
<!-- 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.
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:
: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:
: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:
<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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
children | Snippet | Yes | None | Page content, rendered inside the main landmark. |
header | Snippet | No | None | Site header. Rendered inside a header element (or a div with landmarks={false}); omitted, no header region renders. |
footer | Snippet | No | None | Site footer. Rendered inside a footer element (or a div with landmarks={false}), with muted text as its inherited colour. |
banner | Snippet | No | None | 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#
On this pagePass 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-automax-w-6xlpx-4) inside each snippet and page. - Colours:
--site-shell-surfaceis the page background and the stuck header's fill;--site-shell-inkis the inherited text colour;--site-shell-mutedis the footer's inherited text colour;--site-shell-hairlineoutlines the revealed skip link;--site-shell-accentis 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-heightdefaults 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-heighton :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'sstickyHeaderrather than the header's own sticky prop. - Skip link: set
skipLabelin your site's language. It targetsmainId; changemainIdif your pages already use id="main" for something else.
Public CSS variables
| Variable | Token |
|---|---|
--site-shell-surface | surface |
--site-shell-ink | ink |
--site-shell-muted | muted |
--site-shell-hairline | hairline |
--site-shell-accent | accent |
--site-shell-min-height | minHeight |
--site-shell-header-height | headerHeight |
--site-shell-scroll-gap | scrollGap |
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
skipLabelandbannerLabel.
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.