Content-and-sidebar layout
A fluid main column beside a fixed-width sidebar landmark, parted by one full-height hairline. The sidebar can stick below a page header and scroll on its own, and stacks before or after the content in a narrow column.
cmp_sidebar_layout_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_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
<!--
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>
Usage#
On this pagePass 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- Required props
sidebarchildrensidebarLabel
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) andsite-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' andmobilePosition='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-autowrapper spills past the column. Wrap them, and break long words.
Example
<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.
<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:
: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| Name | Type | Required | Default | Description |
|---|---|---|---|---|
sidebar | Snippet | Yes | None | Supplementary content: section navigation, a table of contents, filters or related details. |
children | Snippet | Yes | None | Main content. |
sidebarLabel | string | Yes | None | Accessible 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. |
sticky | boolean | No | false | Keep 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' | No | side === '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. |
divider | boolean | No | true | Draw the hairline between the sidebar and the main column (horizontal when stacked). Without it the gutter widens from 32 to 48 px. |
class | string | No | None | Extra classes for the root element, for example a margin. |
Customization#
On this pageTwo 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-100fill 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-offsetto the height of your sticky header, for example :root {--sidebar-layout-offset: 4rem }. Insidesite-shell-01withstickyHeaderit follows the shell's measured header on its own; the larger of the two wins. - Divider: divider=
{false}removes the hairline;--sidebar-layout-hairlineretones it. - Dark or tinted page: set
--sidebar-layout-hairline: rgb(255 255 255 / 0.1) and--sidebar-layout-accent:#fafafaon azinc-950page, 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-autoelement inside main; the minmax(0, 1fr) track keeps the sidebar in place.
Public CSS variables
| Variable | Token |
|---|---|
--sidebar-layout-accent | accent |
--sidebar-layout-hairline | hairline |
--sidebar-layout-offset | offset |
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=0so 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-01renders 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=0and a label so it can be scrolled from the keyboard.
Known limitations
- With side and
mobilePositionset 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.