Sticky region
Keeps one block in view while its parent scrolls: a contents rail or order summary held below a sticky header, or a bottom action bar with safe-area padding. Pure CSS, from a chosen breakpoint.
cmp_sticky_region_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_sticky_region_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-3fc47237a5911d8d697b3d0306187e405591105051178a6ace255a5b460cb9e0
<!--
Pins one block to the top or bottom of the viewport while its parent is on screen, with
position: sticky and nothing else: no scroll listeners, no measuring. A top region clears a
sticky header through one shared height variable; a bottom region paints a bar with a hairline
edge and safe-area padding, and lifts only while it is actually stuck (scroll-state queries,
where supported). Below enableAt it is an ordinary block in the flow.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
type Offset = 'none' | 'header' | 'sm' | 'md';
type EnableAt = 'always' | 'md' | 'lg';
interface Props {
/** The block that sticks: a table of contents, an order summary, an action row. */
children: Snippet;
/** Edge of the viewport to stick to. */
position?: 'top' | 'bottom';
/**
* Gap above a top region: none (0), sm (16 px), md (32 px), or header, which is the
* header height (--sticky-region-header-height) plus 16 px. Ignored for bottom.
*/
offset?: Offset;
/** Viewport width from which the region sticks: always, md (768 px) or lg (1024 px). */
enableAt?: EnableAt;
/**
* Cap the region at the viewport height (less its offset) and scroll inside it. A bottom
* bar caps at half the viewport. The scroller is focusable so keyboard users can scroll it.
*/
maxHeight?: boolean;
/** Accessible name for the scroller when maxHeight is set, for example "On this page". */
label?: string;
/** Extra classes for the root element. */
class?: string;
}
let {
children,
position = 'top',
offset = 'sm',
enableAt = 'always',
maxHeight = false,
label,
class: className
}: Props = $props();
/* Sticky only from the breakpoint; below it the region stays in the flow. */
const STICK = {
always: 'sticky',
md: 'static md:sticky',
lg: 'static lg:sticky'
} as const;
/*
* The internal scroller exists only while the region sticks, so phones never nest a scroll,
* and never on paper, where a cap would cut the content off.
*/
const SCROLL = {
always: 'max-h-(--_max) overflow-y-auto overscroll-contain',
md: 'md:max-h-(--_max) md:overflow-y-auto md:overscroll-contain',
lg: 'lg:max-h-(--_max) lg:overflow-y-auto lg:overscroll-contain'
} as const;
const UNCAPPED = 'print:max-h-none print:overflow-visible';
const OFFSETS: readonly Offset[] = ['none', 'header', 'sm', 'md'];
// Unknown values fall back to the defaults rather than rendering a region that never sticks.
const bottom = $derived(position === 'bottom');
const at = $derived(Object.hasOwn(STICK, enableAt) ? enableAt : 'always');
const gap = $derived(OFFSETS.includes(offset) ? offset : 'sm');
</script>
{#snippet body()}
{#if maxHeight}
<!-- Focusable so a keyboard can scroll it (axe scrollable-region-focusable); named when labelled. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class={[
'sticky-region__scroll rounded-[inherit] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)',
SCROLL[at],
UNCAPPED
]}
tabindex="0"
role={label ? 'region' : undefined}
aria-label={label}
>
{@render children()}
</div>
{:else}
{@render children()}
{/if}
{/snippet}
<div
class={[
'sticky-region print:static',
STICK[at],
bottom
? 'sticky-region--bottom bottom-0 z-30'
: 'sticky-region--top top-(--_top) z-10 self-start',
className
]}
data-sticky-region={bottom ? 'bottom' : 'top'}
data-offset={bottom ? undefined : gap}
data-enable-at={at}
>
{#if bottom}
<div
class="sticky-region__bar border-t border-(--_hairline) bg-(--_surface) px-4 pt-3 pb-(--_bar-end) text-(--_ink) shadow-(--_bar-shadow) transition-[box-shadow] duration-150 ease-(--_ease) motion-reduce:transition-none sm:px-6 lg:px-8"
>
{@render body()}
</div>
{:else}
{@render body()}
{/if}
</div>
<style>
/* Public tokens: set --sticky-region-* on this element or any ancestor (often :root). */
.sticky-region {
--_accent: var(--sticky-region-accent, #18181b);
--_ink: var(--sticky-region-ink, #18181b);
--_hairline: var(--sticky-region-hairline, rgb(0 0 0 / 0.08));
--_surface: var(--sticky-region-surface, #ffffff);
--_shadow: var(--sticky-region-shadow, rgb(0 0 0 / 0.08));
--_header: var(--sticky-region-header-height, 4rem);
/* The scrollport's height: 100dvh for the page, or a pane's height inside an app layout. */
--_viewport: var(--sticky-region-viewport, 100dvh);
--_ease: cubic-bezier(0.2, 0, 0, 1);
/* Even all round, so it reads as lift rather than a light source below the bar. */
--_shadow-lift: 0 0 1px var(--_shadow), 0 0 24px var(--_shadow);
}
/* Formulas: where a top region stops, and how tall a capped one may grow below that line. */
.sticky-region--top {
--_top: 1rem;
--_max: calc(var(--_viewport) - var(--_top) - 1rem);
}
.sticky-region[data-offset='none'] {
--_top: 0px;
}
.sticky-region[data-offset='md'] {
--_top: 2rem;
}
.sticky-region[data-offset='header'] {
--_top: calc(var(--_header) + 1rem);
}
/*
* A bottom bar keeps the home indicator clear and, padding and safe area included, never takes
* more than half the screen.
*/
.sticky-region--bottom {
--_bar-end: max(0.75rem, env(safe-area-inset-bottom, 0px));
--_max: calc(var(--_viewport) * 0.5 - 0.75rem - var(--_bar-end) - 1px);
--_bar-shadow: 0 0 #0000;
}
/*
* The bar lifts only while it is stuck over content; resting at the end of its parent it is a
* flat strip on its hairline. Browsers without scroll-state queries keep the flat strip.
*/
@supports (container-type: scroll-state) {
.sticky-region--bottom {
container-type: scroll-state;
}
@container scroll-state(stuck: bottom) {
.sticky-region__bar {
--_bar-shadow: var(--_shadow-lift);
}
}
}
/*
* A capped scroller fades its trailing edge while there is more below, and loses the fade at
* the end. Scroll-driven, so it is position, not motion; an unsupported browser shows no fade.
* A mask clips everything outside the box, focus rings included, so it is only there while
* the scroller scrolls and nothing inside it has focus.
*/
@property --_sticky-region-fade {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
@supports (animation-timeline: scroll()) {
.sticky-region__scroll:not(:focus-within) {
mask-image: linear-gradient(
to bottom,
#000 calc(100% - var(--_sticky-region-fade)),
transparent
);
animation: sticky-region-fade linear both;
animation-timeline: scroll(self);
}
}
@media (width < 48rem) {
.sticky-region[data-enable-at='md'] .sticky-region__scroll {
mask-image: none;
}
}
@media (width < 64rem) {
.sticky-region[data-enable-at='lg'] .sticky-region__scroll {
mask-image: none;
}
}
@media print {
.sticky-region .sticky-region__scroll {
mask-image: none;
}
}
@keyframes sticky-region-fade {
from {
--_sticky-region-fade: 2rem;
}
95% {
--_sticky-region-fade: 2rem;
}
to {
--_sticky-region-fade: 0px;
}
}
</style>
Usage#
On this pageWrap the block that should stay in view and put it where it sticks: last in a sidebar column for a top region, last in the page's main element for a bottom bar. The component only positions the block. A top region paints nothing; a bottom region paints a bar surface. It does not hide on scroll, measure anything, track the active heading, or set scroll padding on your page.
- Suggested location
src/lib/components/sticky-region-01- Required props
children
Limitations
- A sticky element only sticks inside its parent. A top region needs a parent taller than itself (a grid or flex column beside longer content); a bottom bar needs to be the last child of a parent that spans the page.
- overflow: hidden, auto or scroll on any ancestor between the region and the page turns sticking off or makes that ancestor the scroller. Use overflow: clip on the ancestor instead if you need to clip.
- CSS cannot tell whether the block is taller than the screen. Without
maxHeighta block taller than the viewport sticks with its bottom out of reach until the parent ends; setmaxHeightfor anything that can grow (a long contents list, a basket). enableAtuses viewport media queries (768 and 1024 px), not container queries, because sticking is about the screen. Match it to the breakpoint where your layout puts the region beside the content.- With
maxHeight, the scroller stays focusable belowenableAt, where it does not scroll: one extra tab stop on phones. - Two touches are progressive. The bar's lift while stuck needs scroll-state container queries (Chromium 133 and later); elsewhere the bar stays flat on its hairline. The scroller's end fade needs scroll-driven animations (Chromium 115 and later, Safari 26); elsewhere there is no fade. Nothing else depends on either.
Example
<script lang="ts">
import StickyRegion from '$lib/components/sticky-region-01/StickyRegion.svelte';
</script>
<div class="mx-auto grid max-w-6xl gap-10 px-4 py-16 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-16">
<article>
<h1 class="text-3xl font-semibold tracking-tight">Share a board with guests</h1>
</article>
<StickyRegion offset="header" enableAt="lg" maxHeight label="On this page">
<nav aria-label="On this page">
<a href="#invite">Invite a guest</a>
</nav>
</StickyRegion>
</div>Sticky region#
Keeps one block in view while its parent scrolls, with position: sticky and nothing else. It
positions your block; what goes inside is yours.
<div class="grid gap-10 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-16">
<article>…</article>
<StickyRegion offset="header" enableAt="lg">
<OrderSummary />
</StickyRegion>
</div>Choosing the props#
| You have | Use |
|---|---|
| A summary beside a form, under a header | offset="header" enableAt="lg" |
| A contents list on a page with no header | offset="md" enableAt="lg" |
| A contents list that can outgrow the screen | maxHeight label="On this page" |
| A total and a button on phones | position="bottom", last in main, class="md:static" |
Where it sticks#
A sticky element sticks inside its parent and nowhere else.
- Top: put it in a column that is taller than the region, usually a grid track beside the
main content. The root has
self-start, so a grid or flex parent doesn't stretch it to the column's height (a stretched region has nowhere to move). - Bottom: put it last in an element that spans the page, usually
main. It sticks to the bottom edge while that element is on screen and settles after your content at the end, so it never covers the last paragraph. That is the difference fromposition: fixed, which needs matching padding at the end of the page.
Any ancestor with overflow: hidden, auto or scroll breaks this: the region sticks inside
that ancestor instead of the page. Use overflow: clip if you need clipping.
The header offset#
offset="header" stops the region 16 px below --sticky-region-header-height. Set it once:
:root {
--sticky-region-header-height: 4.5rem;
scroll-padding-top: calc(4.5rem + 1rem);
}With site-shell-01, point one variable at the other:
--sticky-region-header-height: var(--site-shell-header-height).
Keeping focus visible#
WCAG 2.2 asks that a focused control is not hidden behind sticky content (SC 2.4.11). The
browser scrolls focused elements clear of scroll-padding, so set it on html (or on the pane
that scrolls, if the region lives in one) for every edge that has something stuck to it. Keep
the bar's height in one variable so the padding follows it:
:root {
--bar-height: 4.5rem; /* the bar's content and padding at your largest text size */
}
html {
scroll-padding-top: calc(var(--sticky-region-header-height) + 1rem);
scroll-padding-bottom: calc(var(--bar-height) + env(safe-area-inset-bottom, 0px) + 1rem);
}The bar's bottom padding grows to env(safe-area-inset-bottom) where the screen has a home
indicator, which is why the padding above allows for it. On iPhones the inset is only non-zero when the page opts into the full screen
with viewport-fit=cover in its viewport meta tag. If your bar can wrap onto two lines at
narrow widths or under zoom, size --bar-height for that, then check by tabbing through the
last fields on the page at 360 px.
Tall content#
CSS can't tell whether a block is taller than the screen, so it can't stop sticking when it is.
maxHeight caps the region at the viewport height less its offset and scrolls inside it, only
while it sticks. The scroller takes keyboard focus and becomes a named region when you pass a
label. Where scroll-driven animations are supported (Chromium, Safari 26), its bottom edge fades
while there is more below. The fade is dropped while anything inside has focus, so focus rings
are never clipped.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
children | Snippet | Yes | None | The block that stays in view: a contents list, an order summary, an action row. |
position | 'top' | 'bottom' | No | 'top' | Edge of the viewport to stick to. bottom paints a bar with a hairline top edge and safe-area padding. |
offset | 'none' | 'header' | 'sm' | 'md' | No | 'sm' | Space above a top region once stuck: 0, 16 px, 32 px, or header, which is --sticky-region-header-height plus 16 px. Ignored for bottom. |
enableAt | 'always' | 'md' | 'lg' | No | 'always' | Viewport width from which the region sticks: always, md (768 px) or lg (1024 px). Below it the region is an ordinary block. |
maxHeight | boolean | No | false | Cap the region at the viewport height less its offset (a bottom bar, padding included, at half the viewport) and scroll inside it, only while it sticks and never in print. The scroller is focusable. |
label | string | No | None | Accessible name for the scroller when maxHeight is set, which makes it a named region. Set it whenever you set maxHeight. |
class | string | No | None | Extra classes for the root element, for example md:static to stop a bottom bar sticking on wide screens. |
Customization#
On this pageSet the header height once on :root so a top region clears your header, and retone the bottom bar through its surface, hairline, ink and shadow colour (the soft lift it gets while stuck). Offsets and breakpoints are static class maps and CSS variables at the top of the source.
- Header offset: set
--sticky-region-header-heighton :root to your sticky header's height (4rem by default). Withsite-shell-01, point one at the other:--sticky-region-header-height: var(--site-shell-header-height). - Focus not obscured (WCAG 2.4.11): set scroll-padding-top on html to the header height plus a gap, and with a bottom bar set scroll-padding-bottom to the bar's height plus env(safe-area-inset-bottom, 0px) plus a gap, so the browser scrolls focused fields clear of both. Inside a scrolling pane, set it on the pane instead.
- Bottom bar: put the region last in your main element. Because it is sticky, not fixed, it ends up in the flow after your content and covers nothing at the end of the page; scroll-padding-bottom covers the rest.
- Bar colour:
--sticky-region-surface,--sticky-region-inkand--sticky-region-hairline. For a dark bar:--sticky-region-surface:#18181b;--sticky-region-ink:#fafafa;--sticky-region-hairline: rgb(255 255 255 / 0.1);--sticky-region-shadow: rgb(0 0 0 / 0.4). - Bottom bar on phones only: pass class="
md:static" to stop it sticking from 768 px, or hide the bar there withmd:hiddenand show the same action in your sidebar instead. - Inside a scrolling pane rather than the page: set
--sticky-region-viewportto the pane's height somaxHeightcaps against the pane, not the screen. - Offsets: change the --_top values in the style block (16 px, 32 px, header plus 16 px) to move every top region at once.
Public CSS variables
| Variable | Token |
|---|---|
--sticky-region-accent | accent |
--sticky-region-ink | ink |
--sticky-region-hairline | hairline |
--sticky-region-surface | surface |
--sticky-region-shadow | shadow |
--sticky-region-header-height | headerHeight |
--sticky-region-viewport | viewport |
Accessibility#
On this page- The region renders a plain div with no role or landmark. Wrap a contents list in nav with a label, and a summary in a section or aside with a heading, inside the snippet.
- A sticky region can cover focused controls (WCAG 2.2 SC 2.4.11). Set scroll-padding-top on html to your header height plus a gap, and scroll-padding-bottom to the bottom bar's height plus a gap, so tabbing and anchor links land clear of both.
- With
maxHeight, the scroller has tabindex="0" so keyboard users can scroll it with the arrow keys, and role="region" with your label as its name. Without a label it is focusable but unnamed; always pass one. - The scroller's focus ring is a 2 px outline in the accent with a 2 px offset. The end fade is a mask, which would clip focus rings, so it is dropped while the scroller or anything inside it has focus.
- Position never changes the DOM order: the region is read and focused where it sits in your markup. Put a top region after the main content and a bottom bar last in main.
- Printing turns stickiness off.
Known limitations
- There is no pure-CSS way to stop a block sticking when it is taller than the screen;
maxHeightcaps and scrolls it instead.
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.