Skip to content
Palette Default
Download ZIP

Default palette · 7.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_sidebar_layout_01 · version 1.0.0 · Default palette
Using the PageSugar MCP server, fetch component cmp_sidebar_layout_01 version 1.0.0 with variant "default", 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
Default
Version
1.0.0
Digest
Full digest
sha256-24dac8adfc45918536a8236a4ef956f48c6f742b7a457c7599cc76eb1809dc40
SidebarLayout.svelte Svelte · 9.3 KB Raw
<!--
	A fixed-width rail beside a fluid main column, parted by one full-height hairline. The rail is
	a labelled aside or nav; with sticky set it rides that hairline below the page header and
	scrolls on its own when it is taller than the screen. Below the breakpoint the two stack and
	the hairline turns horizontal. The breakpoint is a container query on the root (56rem), so
	the layout measures the column it sits in, not the viewport.

	The DOM order follows mobilePosition (sidebar first for 'before'), so stacked reading and
	focus order match what is on screen; side only places the columns once side by side.
-->
<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** Supplementary content: a table of contents, filters, section navigation, related links. */
		sidebar: Snippet;
		/** Main content. */
		children: Snippet;
		/** Accessible name for the sidebar landmark, such as "On this page" or "Filters". */
		sidebarLabel: string;
		/** Which side the sidebar takes once side by side, in the writing direction. */
		side?: 'start' | 'end';
		/** Sidebar width once side by side: sm 14rem, md 18rem, lg 22rem. */
		sidebarWidth?: 'sm' | 'md' | 'lg';
		/** Keep the sidebar in view while the main column scrolls, with its own scroll when tall. */
		sticky?: boolean;
		/** Landmark element: nav for links that navigate, aside for filters and related content. */
		sidebarElement?: 'aside' | 'nav';
		/** Where the sidebar sits when stacked. Defaults to before for a start sidebar, after for an end one. */
		mobilePosition?: 'before' | 'after';
		/** Draw the hairline between the sidebar and the main column. */
		divider?: boolean;
		/** Extra classes for the root element. */
		class?: string;
	}

	let {
		sidebar,
		children,
		sidebarLabel,
		side = 'start',
		sidebarWidth = 'md',
		sticky = false,
		sidebarElement = 'aside',
		mobilePosition,
		divider = true,
		class: className
	}: Props = $props();

	/* Width presets, read by the grid template as --_width. Add a size here to add a preset. */
	const WIDTH = {
		sm: '[--_width:14rem]',
		md: '[--_width:18rem]',
		lg: '[--_width:22rem]'
	} as const;

	// Unknown values fall back to the defaults rather than rendering an unstyled grid.
	const end = $derived(side === 'end');
	const element = $derived(sidebarElement === 'nav' ? 'nav' : 'aside');
	const before = $derived(mobilePosition ? mobilePosition !== 'after' : !end);
	const widthClass = $derived(Object.hasOwn(WIDTH, sidebarWidth) ? WIDTH[sidebarWidth] : WIDTH.md);
	/* A blank label would leave an unnamed landmark. */
	const label = $derived(
		sidebarLabel?.trim() || (element === 'nav' ? 'Section navigation' : 'Related information')
	);

	let rail = $state<HTMLElement>();
	let scrollable = $state(false);

	/*
	 * A sticky rail that overflows is a scroll container. When nothing inside it can take focus,
	 * it becomes a tab stop itself so the keyboard can scroll it; when links or fields are inside,
	 * tabbing to them scrolls it already and an extra stop would only be noise.
	 */
	$effect(() => {
		if (!sticky || !rail) return;
		const target = rail;
		const candidates =
			'a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]';
		/* In the tab order for real: not tabindex=-1, not disabled (fieldsets included), not inert, rendered. */
		const tabbable = (el: HTMLElement) =>
			el.tabIndex >= 0 &&
			!el.matches(':disabled') &&
			!el.closest('[inert]') &&
			el.getClientRects().length > 0;
		const measure = () => {
			const overflows = target.scrollHeight > target.clientHeight + 1;
			scrollable =
				overflows && ![...target.querySelectorAll<HTMLElement>(candidates)].some(tabbable);
		};
		measure();
		const resize = new ResizeObserver(measure);
		resize.observe(target);
		if (target.firstElementChild) resize.observe(target.firstElementChild);
		// Content, text and the attributes that move an element in or out of the tab order.
		const mutate = new MutationObserver((records) => {
			if (records.every((r) => r.target === target && r.attributeName === 'tabindex')) return;
			measure();
		});
		mutate.observe(target, {
			childList: true,
			subtree: true,
			characterData: true,
			attributes: true,
			attributeFilter: [
				'disabled',
				'href',
				'tabindex',
				'hidden',
				'inert',
				'contenteditable',
				'class',
				'style'
			]
		});
		return () => {
			resize.disconnect();
			mutate.disconnect();
			scrollable = false;
		};
	});
</script>

{#snippet railRegion()}
	<!-- The track stretches to the row's height and carries the hairline; the landmark rides inside it. -->
	<div
		class={[
			'min-w-0',
			end ? '@4xl:col-start-2' : '@4xl:col-start-1',
			'@4xl:row-start-1',
			divider && [
				'border-(--_hairline)',
				before ? 'border-b pb-8' : 'border-t pt-8',
				'@4xl:border-t-0 @4xl:border-b-0 @4xl:pt-0 @4xl:pb-0',
				end ? '@4xl:border-s' : '@4xl:border-e'
			]
		]}
		data-sidebar-layout-track
	>
		<svelte:element
			this={element}
			bind:this={rail}
			aria-label={label}
			tabindex={scrollable ? 0 : undefined}
			class={[
				'sidebar-layout__rail min-w-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)',
				divider && (end ? '@4xl:ps-8' : '@4xl:pe-8'),
				sticky && [
					'@4xl:sticky @4xl:top-(--_top) @4xl:max-h-(--_max-height) @4xl:overflow-y-auto @4xl:overscroll-contain',
					/*
					 * A scroller clips its children, focus rings included. Four pixels of padding, taken
					 * back by a negative margin, give a 2 px ring at a 2 px offset room on every side the
					 * divider padding does not already cover, without moving the content edge.
					 */
					'@4xl:-my-1 @4xl:py-1',
					!divider ? '@4xl:-mx-1 @4xl:px-1' : end ? '@4xl:-me-1 @4xl:pe-1' : '@4xl:-ms-1 @4xl:ps-1'
				]
			]}
			data-sticky={sticky ? '' : undefined}
		>
			<div>{@render sidebar()}</div>
		</svelte:element>
	</div>
{/snippet}

{#snippet mainRegion()}
	<!-- min-w-0 and the minmax(0, 1fr) track keep a wide table or code block from pushing the rail off screen. -->
	<div
		class={['min-w-0', end ? '@4xl:col-start-1' : '@4xl:col-start-2', '@4xl:row-start-1']}
		data-sidebar-layout-main
	>
		{@render children()}
	</div>
{/snippet}

<!-- Inline-size containment ignores content width, so the root claims its parent's width. -->
<div class={['sidebar-layout @container w-full min-w-0', widthClass, className]}>
	<div
		class={[
			'grid grid-cols-1 gap-y-8',
			end
				? '@4xl:grid-cols-[minmax(0,1fr)_var(--_width)]'
				: '@4xl:grid-cols-[var(--_width)_minmax(0,1fr)]',
			divider ? '@4xl:gap-x-8' : '@4xl:gap-x-12'
		]}
	>
		{#if before}
			{@render railRegion()}
			{@render mainRegion()}
		{:else}
			{@render mainRegion()}
			{@render railRegion()}
		{/if}
	</div>
</div>

<style>
	/* Public tokens: set --sidebar-layout-* on the layout or any ancestor to retone it. */
	.sidebar-layout {
		--_accent: var(--sidebar-layout-accent, #18181b);
		--_hairline: var(--sidebar-layout-hairline, rgb(0 0 0 / 0.08));
		--_offset: var(--sidebar-layout-offset, 0px);

		/*
		 * Formulas for the sticky rail. The offset is the height of whatever sticks above it: the
		 * larger of --sidebar-layout-offset and site-shell-01's measured header height (or that
		 * shell's estimate before JavaScript runs), so inside that shell it needs no setting. The
		 * rail stops 2rem below the offset and ends 2rem above the bottom of the screen, so its
		 * last item is never cut off.
		 */
		--_top: calc(
			max(var(--_offset), var(--_site-shell-header, var(--_site-shell-estimate, 0px))) + 2rem
		);
		--_max-height: calc(100dvh - var(--_top) - 2rem);
	}

	/*
	 * Scrollbar and edge fade for the scrolling rail, which only scrolls once side by side (the
	 * same 56rem container width as the @4xl: utilities). Stacked, none of this applies, so a mask
	 * never clips the focus rings of links that run the full width. A thin scrollbar in the
	 * hairline's tone sits against the divider, and its gutter is reserved so nothing jumps.
	 */
	@container (min-width: 56rem) {
		.sidebar-layout__rail[data-sticky] {
			scrollbar-width: thin;
			scrollbar-color: color-mix(in srgb, var(--_hairline), currentColor 12%) transparent;
			scrollbar-gutter: stable;
		}
	}

	/*
	 * When the rail overflows, its top and bottom edges fade to say there is more. Scroll-driven,
	 * so it needs no listener: a rail that does not scroll has no active timeline and no fade, and
	 * each fade clears as that end is reached.
	 */
	@property --_fade-start {
		syntax: '<length>';
		inherits: false;
		initial-value: 0px;
	}
	@property --_fade-end {
		syntax: '<length>';
		inherits: false;
		initial-value: 0px;
	}
	@supports (animation-timeline: scroll()) {
		@container (min-width: 56rem) {
			.sidebar-layout__rail[data-sticky] {
				mask-image: linear-gradient(
					to bottom,
					transparent,
					#000 var(--_fade-start),
					#000 calc(100% - var(--_fade-end)),
					transparent
				);
				animation: sidebar-layout-fade linear both;
				animation-timeline: scroll(self block);
			}
			/* Masks reach descendants too, so the fade lifts while anything inside has keyboard focus. */
			.sidebar-layout__rail[data-sticky]:focus-visible,
			.sidebar-layout__rail[data-sticky]:has(:focus-visible) {
				mask-image: none;
			}
		}
	}
	@keyframes sidebar-layout-fade {
		0% {
			--_fade-start: 0px;
			--_fade-end: 2rem;
		}
		4% {
			--_fade-start: 2rem;
		}
		96% {
			--_fade-end: 2rem;
		}
		100% {
			--_fade-start: 2rem;
			--_fade-end: 0px;
		}
	}
</style>

Pass the main content as children and the sidebar as a snippet, and name the sidebar with sidebarLabel. The layout paints only the hairline between the two and a focus ring on a scrolling sidebar; it sets no type, adds no section padding or max width and draws no header, so put it inside your own page container. It has no off-canvas drawer: on a narrow screen the sidebar stacks before or after the content, and a collapsible filter panel or drawer is yours to add.

Suggested location
src/lib/components/sidebar-layout-01

Limitations

  • The breakpoint is a container query on the root at 56rem (896 px at the default root size), so a layout inside a narrow column stays stacked on a wide screen. Browsers without container queries (before 2023) always show the stacked layout.
  • Sticky needs the page (or one scroll container you choose) to be what scrolls: an ancestor with overflow: hidden, auto or scroll between the layout and the page becomes the thing the sidebar sticks to, and it stops sticking. The sidebar sticks only once side by side; stacked, it scrolls with the page.
  • The sticky offset is the larger of --sidebar-layout-offset (default 0) and site-shell-01's measured header height (or that shell's estimate before JavaScript runs). Any other sticky header needs the variable set to its height.
  • The sticky sidebar's height is calc(100dvh - offset - 4rem). Browsers before 2023 without dvh drop the max height and the sidebar no longer scrolls on its own.
  • A scrolling sidebar with no focusable content becomes a tab stop once JavaScript has measured it; before hydration, or with JavaScript off, the keyboard reaches it only in browsers that make scrollers focusable themselves (Chromium 130 and later).
  • The edge fade uses scroll-driven animations and @property (Chromium 115, Safari 26). Elsewhere the sidebar scrolls without the fade.
  • side places the columns visually; the DOM order follows mobilePosition. With side='start' and mobilePosition='after' (as for long section navigation that should not push the article down a phone screen) the sidebar is read after the content but shown before it on a wide screen. That order is meaningful (content first), but focus then crosses the page once.
  • The main track shrinks, but content inside it does not wrap by itself: a wide table or code block without its own overflow-x-auto wrapper spills past the column. Wrap them, and break long words.

Example

Svelte
<script lang="ts">
	import SidebarLayout from '$lib/components/sidebar-layout-01/SidebarLayout.svelte';
</script>

<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 lg:px-8">
	<SidebarLayout sidebarLabel="Docs" sidebarElement="nav" sidebarWidth="sm" sticky>
		{#snippet sidebar()}
			<ul class="space-y-1 text-sm">
				<li><a href="/docs/timelines">Plan on a timeline</a></li>
				<li><a href="/docs/dependencies" aria-current="page">Link dependent tasks</a></li>
			</ul>
		{/snippet}
		<article>
			<h1 class="text-3xl font-semibold tracking-tight text-zinc-950">Link dependent tasks</h1>
			<p class="mt-4 text-lg text-zinc-600">Move the first task and every task that waits on it moves too.</p>
		</article>
	</SidebarLayout>
</div>

Content-and-sidebar layout#

A fluid main column beside a fixed-width sidebar landmark, with one hairline between them. Put it inside your own page container and give both regions their own content.

Svelte
<SidebarLayout sidebarLabel="On this page" sidebarElement="nav" side="end" sidebarWidth="sm" sticky>
	{#snippet sidebar()}…contents…{/snippet}
	<article>…</article>
</SidebarLayout>

Choosing the props#

You have Use
Docs or help section navigation side="start", sidebarElement="nav", sm or md, sticky
An "On this page" contents list side="end", sidebarElement="nav", sm, sticky
Filters beside a listing side="start", sidebarElement="aside", md
Details or related links side="end", sidebarElement="aside", lg

Sticky and the header#

A sticky sidebar stops 2rem below --sidebar-layout-offset and ends 2rem above the bottom of the screen; anything taller scrolls inside it, with its top and bottom edges fading while there is more to see. Set the offset to your sticky header's height:

CSS
:root {
	--sidebar-layout-offset: 4rem;
}

Inside site-shell-01 with stickyHeader, you can leave it unset: the layout takes the larger of the variable and the shell's measured header height. An ancestor with overflow: hidden, auto or scroll between the layout and the page becomes the scroll container the sidebar sticks to, so it stops sticking.

Order#

The DOM order follows mobilePosition: before puts the sidebar first, after puts it after the content. It defaults to before for a start sidebar and after for an end one, so the reading order matches the screen at every width. Filters usually belong before the results; a table of contents after the article. Long section navigation with no drawer of your own also reads better after the article on a phone (mobilePosition="after"): the visitor opened the page for the article, and keyboard users then reach it before the menu on every screen.

Container, not viewport#

The split happens when the layout itself is 56rem wide (896 px at the default root size), so it stays stacked inside a narrow column on a desktop. Swap the @4xl: prefixes for lg: if you want a viewport breakpoint.

Wide content#

The main track is minmax(0, 1fr) with min-w-0, so a wide table or code block cannot push the sidebar off screen, but unwrapped it spills past the column: wrap it in an element with overflow-x-auto, tabindex="0" and a label.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
sidebarSnippetYesNoneSupplementary content: section navigation, a table of contents, filters or related details.
childrenSnippetYesNoneMain content.
sidebarLabelstringYesNoneAccessible name for the sidebar landmark, such as 'On this page' or 'Filter courses'. A blank value falls back to 'Section navigation' for nav and 'Related information' for aside.
side'start' | 'end'No'start'Which side the sidebar takes once side by side, in the writing direction: start is the left in left-to-right text and the right in right-to-left.
sidebarWidth'sm' | 'md' | 'lg'No'md'Sidebar width once side by side: sm 14rem for a contents list, md 18rem for navigation and filters, lg 22rem for details panels.
stickybooleanNofalseKeep the sidebar in view below the page header while the main column scrolls, with its own scroll when it is taller than the screen.
sidebarElement'aside' | 'nav'No'aside'Landmark element: nav for links that navigate (section navigation, a table of contents), aside for filters and related content.
mobilePosition'before' | 'after'Noside === 'end' ? 'after' : 'before'Where the sidebar sits when stacked. It also sets the DOM order, so the reading and focus order match the stacked layout.
dividerbooleanNotrueDraw the hairline between the sidebar and the main column (horizontal when stacked). Without it the gutter widens from 32 to 48 px.
classstringNoNoneExtra classes for the root element, for example a margin.

Customization#

On this page

Two colour tokens (the hairline and the focus ring) and the sticky offset. Everything else is in your snippets: the layout sets no type, fills or padding of its own.

  • Content: the sidebar and main content are yours. Style links, filters and articles in the snippets; the preview's rail uses 36 px link rows with the current page marked by a zinc-100 fill and weight 500.
  • Width: pick sm (14rem), md (18rem) or lg (22rem). For another width, change or add an entry in the WIDTH map at the top of the source, for example md: '[--_width:16rem]'.
  • Sticky offset: set --sidebar-layout-offset to the height of your sticky header, for example :root { --sidebar-layout-offset: 4rem }. Inside site-shell-01 with stickyHeader it follows the shell's measured header on its own; the larger of the two wins.
  • Divider: divider={false} removes the hairline; --sidebar-layout-hairline retones it.
  • Dark or tinted page: set --sidebar-layout-hairline: rgb(255 255 255 / 0.1) and --sidebar-layout-accent: #fafafa on a zinc-950 page, or a hairline of rgb(68 40 20 / 0.12) on a cream bakery page, so the rule stays one quiet pixel.
  • Breakpoint: the @4xl: prefixes (56rem container) control the split. Swap them for @3xl: or @5xl:, or for lg: if you want a viewport media query instead.
  • Wide content: wrap tables and code in an overflow-x-auto element inside main; the minmax(0, 1fr) track keeps the sidebar in place.

Public CSS variables

VariableToken
--sidebar-layout-accentaccent
--sidebar-layout-hairlinehairline
--sidebar-layout-offsetoffset

Accessibility#

On this page
  • The sidebar is an aside (complementary) or nav landmark named by sidebarLabel. Give each landmark of the same type on a page a different label.
  • The DOM order follows mobilePosition, so stacked reading and focus order match the screen. side only places the columns once side by side.
  • A sticky sidebar that overflows and holds nothing focusable gets tabindex=0 so the keyboard can scroll it, with a 2 px accent focus ring. When it holds links or fields, tabbing to them scrolls it and no extra tab stop is added.
  • The main column is a plain div. Put it inside your page's main landmark (site-shell-01 renders one) and give it its own headings.
  • Mark the current link in a navigation sidebar with aria-current='page', or 'location' for a table of contents.
  • Wide tables and code in main scroll inside their own wrappers; give each scrolling wrapper tabindex=0 and a label so it can be scrolled from the keyboard.

Known limitations

  • With side and mobilePosition set against each other (side='start' with 'after', or side='end' with 'before'), the wide layout shows the sidebar on the opposite side from where it falls in the reading and focus order. CSS reading-flow, which would fix that, is not used because it is not yet supported across browsers.

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 · 1 October 2026

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