Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c29a3ad
fix(ui): stop the dialog scroll lock shifting a fixed header
vivek7405 Jul 27, 2026
6618e68
test(ui): isolate the scroll-lock tests from each other on failure
vivek7405 Jul 27, 2026
e758a73
docs(ui): document the dialog scroll lock's fixed-header contract
vivek7405 Jul 27, 2026
052b954
fix(ui): stop the scroll lock clobbering a page's own layout choices
vivek7405 Jul 27, 2026
8fa72b5
fix(ui): make the scroll lock safe to release out of order
vivek7405 Jul 27, 2026
7dd22bd
fix(website): keep the header chrome full bleed while compensating it
vivek7405 Jul 27, 2026
98c4f03
fix(ui): release only the scroll lock a dialog actually took
vivek7405 Jul 27, 2026
d1fda36
refactor(ui): opt in with a transparent border, not a padding calc
vivek7405 Jul 27, 2026
611239f
chore: drop two scratch probe scripts committed by accident
vivek7405 Jul 27, 2026
0feb745
fix(ui): make the lock idempotent and inset the element that actually…
vivek7405 Jul 27, 2026
62d1bed
fix(ui): bring the blog's lock copy fully in lockstep, unrig two tests
vivek7405 Jul 27, 2026
bfd75e3
test(blog): pin the fixed header's scroll-lock opt-in
vivek7405 Jul 28, 2026
977a8b0
docs(ui): stop the unlock comment contradicting the guard above it
vivek7405 Jul 28, 2026
2cd07e6
test(ui): make the left-aligned probe actually able to catch the bug
vivek7405 Jul 28, 2026
0d307cf
test(ui): give the fixture the flex-1 shape that makes both probes count
vivek7405 Jul 28, 2026
f03144d
test(ui): cover a dialog whose own content scrolls
vivek7405 Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .agents/skills/webjs/references/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,32 @@ new ResizeObserver(apply).observe(hdr);
```

For a dashboard, an alternative is an app-shell scroll container (a non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which needs no offset but changes the scroll model.

### A fixed header and a modal that locks scroll

Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides the page scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. The usual compensation is padding the body, which holds in-flow content still. **It does nothing for a fixed header**, because a fixed box lays out against the initial containing block, never against the body's padding box, so the header widens with the viewport and its centred content slides right by half the scrollbar width.

`@webjsdev/ui`'s `<ui-dialog>` / `<ui-alert-dialog>` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. It leaves the gutter alone if your page already declared its own `scrollbar-gutter`, on the assumption that a page which made that choice meant it.

Engines differ, and the lock does not need to know which one it is on. Where the gutter is honoured (measured on Chromium) nothing moves and the lock does nothing else. Where it is ignored (measured on WebKit) the viewport does widen, so the lock measures how much, pads `<html>` by it to hold in-flow content still (added to any padding you already had, and restored on close), and publishes the amount as `--wj-scrollbar-compensation` so a fixed element can opt in with one line:

```css
header { position: fixed; inset-inline: 0; top: 0; border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
```

A transparent border rather than `padding-right`, for two reasons. It composes with whatever padding the element already has, where a padding form has to restate that base value and restate it again per responsive variant. And a background still paints across a border, so a header that carries its own background stays full bleed instead of ending short of the edge. Declare it after your Tailwind link if you use the `border-*` utilities, since those set `border-right-color` too.

The property is only set while a lock is active AND the viewport actually widened, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. If you find inline `padding-right` on your `<html>` while a modal is open, that is this, and it comes off on close.

**Put it on the element that is both viewport-width and painting.** Both halves are load-bearing, and getting either wrong fails quietly.

- **Viewport-width**, or a left-aligned child still moves. Insetting a `max-width` container holds its CENTRED children still but not its leading ones, because the container's own box is not what widened. This is easy to miss: measure a left-aligned child, not just a centred one.
- **Painting**, or the background stops short of the widened edge. Insetting a wrapper that paints nothing insets the child that does, which leaves an unpainted strip.

In this repo `examples/blog` has one element that is both (the fixed header paints its own chrome, so the border goes straight on it), and `website` has a non-painting fixed wrapper around a painting header around a centring bar, where the header is the one that qualifies.

Rolling your own scroll lock? Three things the kit learned the hard way, and the first is that almost every implementation of this (including Next's own dev overlay and Radix) gets the fixed case wrong by design.

1. Reserve the gutter rather than relying on padding alone, or a fixed element will jump however carefully you compensate the body.
2. When you do pad, pad `<html>`, not `<body>`. A `max-width` body does not widen when the viewport does, so padding it misses the shift entirely, while padding the root holds in-flow content whatever the body's width is.
3. Measure the root's own border box to decide the amount. `documentElement.clientWidth` can grow while nothing actually moved (Chromium reports exactly that under a reserved gutter), and a `position: fixed` probe reads its pre-lock box on WebKit until the next rendering update, so both mislead.
12 changes: 12 additions & 0 deletions examples/blog/app/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,18 @@ export default function RootLayout({ children }: LayoutProps) {
radial-gradient(58% 44% at 50% -4%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 100%), transparent), transparent 72%),
radial-gradient(40% 36% at 88% 8%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 60%), transparent), transparent 70%);
}
/* #1144: while a modal holds the page scroll lock, an engine that ignores
scrollbar-gutter widens the viewport, and this header is position: fixed
so it lays out against the viewport and cannot be reached by the padding
that holds in-flow content still. A TRANSPARENT RIGHT BORDER is the
opt-in: it insets the content by the published amount while the
background still paints across it (backgrounds fill the border box), so
the chrome stays full bleed. A border also composes with whatever
padding the element already has, which padding-right cannot: that form
has to restate the base value, once per responsive variant. Declared
after the Tailwind link above, so it wins the border-right-color the
border-* utilities also set. Resolves to 0px at every other moment. */
.site-header { border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
::selection { background: var(--primary-tint); color: var(--foreground); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
Expand Down
166 changes: 148 additions & 18 deletions examples/blog/components/ui/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@
* within the dialog (native focus trap).
*
* Design tokens used: --background, --border, --muted-foreground.
*
* Scroll lock (#1144): opening the dialog locks body scroll, which hides the
* page scrollbar and would widen the viewport. The lock reserves the scrollbar
* gutter so the width never changes and a `position: fixed` header stays put.
* Where the engine ignores `scrollbar-gutter` (WebKit today) it publishes the
* leftover width on <html> as `--wj-scrollbar-compensation`, so a fixed header
* opts in with `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`
* (a transparent border composes with existing padding, and a background paints
* across it, so a header keeps its own chrome full bleed).
*/
import { WebComponent, html, unsafeHTML, prop } from '@webjsdev/core';
import { ref, createRef } from '@webjsdev/core/directives';
Expand Down Expand Up @@ -122,30 +131,130 @@ function installStyles(): void {
}

// --------------------------------------------------------------------------
// Body scroll lock. Refcounted so nested dialogs unlock in order. Native
// <dialog> does not lock body scroll, only inert-ifies the background.
// Page scroll lock, refcounted so nested dialogs release safely in ANY order.
// Native <dialog> does not lock scrolling, it only inert-ifies the background.
//
// Hiding the page scrollbar widens the viewport by the scrollbar's width, so
// anything laid out against the viewport moves. Padding the page holds in-flow
Comment thread
vivek7405 marked this conversation as resolved.
// content still, but a `position: fixed` header lays out against the initial
// containing block, never against any padding box, so padding alone leaves it
// sliding right by half the scrollbar width. Two mechanisms fix it:
//
// 1. Reserve the scrollbar gutter for the duration of the lock, so the
// viewport width never changes and NOTHING moves, in flow or fixed. This
// needs no cooperation from the page. Measured honoured on Chromium.
// 2. Where the engine ignores it (measured on WebKit), fall back to
// padding <html> and publish the leftover width as
// `--wj-scrollbar-compensation` on it, so a fixed element can opt in with
// `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`.
Comment thread
vivek7405 marked this conversation as resolved.
//
// Everything below is MEASURED rather than assumed, because engines disagree
// about both scrollbar geometry and gutter support. When mechanism 1 works the
// measured residual is zero, so no padding is applied and the custom property is
// never set: the two mechanisms cannot double-compensate.
// --------------------------------------------------------------------------

let scrollLockCount = 0;
let savedOverflow = '';
let savedPaddingRight = '';
// The lock's state is DOCUMENT level, so it is keyed on globalThis rather than
// module scope. dialog.ts and alert-dialog.ts ship as separate copies (so
// `webjs ui add alert-dialog` stays self contained), and two independent
// counters mutating the same <html> can only be released safely in LIFO order.
// They are not: `disconnectedCallback` fires in tree order and the #766
// before-cache close runs in registration order, so a confirm inside a dialog
// releases OUTER first, and the inner unlock would then re-apply the values it
// captured with nothing left to clear them, leaving <html> padded for good. One
// shared counter makes release order irrelevant.
interface ScrollLockState {
count: number;
overflow: string;
rootPaddingRight: string;
scrollbarGutter: string;
compensation: string;
}

const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';

function scrollLockState(): ScrollLockState {
// The key follows the framework's global-key prefix (see `__webjsSonnerBus` in
// sonner.ts, the same pattern for the same reason), and the cast is typed
// rather than a string-keyed bag, so a change to the shape is a type error in
// both copies instead of a silent undefined at runtime.
const store = globalThis as unknown as { __webjsScrollLock?: ScrollLockState };
let state = store.__webjsScrollLock;
if (!state) {
state = { count: 0, overflow: '', rootPaddingRight: '', scrollbarGutter: '', compensation: '' };
store.__webjsScrollLock = state;
}
return state;
}

function lockScroll(): void {
if (scrollLockCount === 0) {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
savedOverflow = document.body.style.overflow;
savedPaddingRight = document.body.style.paddingRight;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
const state = scrollLockState();
if (state.count === 0) {
const root = document.documentElement;
const body = document.body;
const rootStyle = getComputedStyle(root);

state.overflow = body.style.overflow;
state.rootPaddingRight = root.style.paddingRight;
state.scrollbarGutter = root.style.scrollbarGutter;
state.compensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);

// <html> is an in-flow block filling the initial containing block, so its
// border box IS the viewport width, and it is re-laid-out synchronously.
// A `position: fixed` probe is NOT a substitute: WebKit keeps reporting its
// old box until the next rendering update, so it reads a zero delta and the
// compensation below never fires.
const widthBefore = root.getBoundingClientRect().width;
const padBefore = parseFloat(rootStyle.paddingRight) || 0;
// An engine with no `scrollbar-gutter` at all reads back undefined, which
// must be treated as "the page has not chosen" so the gutter is still
// attempted (setting an unsupported property is a harmless no-op, and the
// measured residual below is what actually decides the fallback).
const chosenGutter = rootStyle.scrollbarGutter || 'auto';

// Reserve the gutter, but only when the page has not already made its own
// choice. An app running `stable both-edges` keeps both gutters through the
// lock, and overwriting that with the single-edge value would drop one and
// introduce the very shift this exists to prevent.
if (chosenGutter === 'auto' && window.innerWidth > root.clientWidth) {
root.style.scrollbarGutter = 'stable';
}
body.style.overflow = 'hidden';

const grew = root.getBoundingClientRect().width - widthBefore;
if (grew > 0) {
// Padding the ROOT holds in-flow content still whatever the body's own
// width is (a `max-width` body does not widen with the viewport, so
// padding the body would miss it), and it leaves the page's own body
// padding untouched.
root.style.paddingRight = `${padBefore + grew}px`;
// A fixed box lays out against the viewport, so no padding here can reach
// it. Publish what it moved by and let it opt in.
root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
}
}
scrollLockCount++;
state.count++;
}

function unlockScroll(): void {
scrollLockCount = Math.max(0, scrollLockCount - 1);
if (scrollLockCount === 0) {
document.body.style.overflow = savedOverflow;
document.body.style.paddingRight = savedPaddingRight;
const state = scrollLockState();
// An unlock with no matching lock is a no-op, NOT the last release: clamping to
// zero and restoring would replay a stale snapshot onto whatever the page owns
// now. Belt-and-braces as things stand, because _teardown() only unlocks what
// its own element locked, so no path through the components can reach here with
// a zero count. It is kept because the cost is one comparison and the failure
// it prevents is silent.
if (state.count === 0) return;
state.count--;
if (state.count === 0) {
const root = document.documentElement;
document.body.style.overflow = state.overflow;
root.style.paddingRight = state.rootPaddingRight;
root.style.scrollbarGutter = state.scrollbarGutter;
// Restored rather than removed, so a value the PAGE set before any dialog
// opened survives the lock.
if (state.compensation) root.style.setProperty(SCROLLBAR_COMPENSATION, state.compensation);
else root.style.removeProperty(SCROLLBAR_COMPENSATION);
}
}

Expand Down Expand Up @@ -212,15 +321,36 @@ export class UiDialog extends WebComponent({
return this.querySelector('ui-dialog-content') as UiDialogContent | null;
}

_scrollLocked?: boolean;

_setup(): void {
// A detached element must not lock: `updated()` defers this to a microtask,
// and a removal in between already ran _teardown() as a no-op, so locking
// here would never be released (nothing disconnects twice).
if (!this.isConnected) return;
const content = this._content;
if (!content) return;
lockScroll();
// Idempotent, because the count is an integer and the flag is a boolean. A
// coalesced hide() then show() in one task produces ONE update whose
// changedProperties still reports open, so _setup() can run twice with no
// _teardown() between; double-counting there would leave the page locked for
// the rest of the session.
if (!this._scrollLocked) {
lockScroll();
this._scrollLocked = true;
}
content.showModal();
}

_teardown(): void {
unlockScroll();
// Release only what THIS element locked. _setup() returns before locking
// when there is no content child, so an unconditional unlock here would
// consume ANOTHER open dialog's count and restore the page out from under
// it, dropping its compensation while it is still open.
if (this._scrollLocked) {
this._scrollLocked = false;
unlockScroll();
}
this._content?.close();
}
}
Expand Down
86 changes: 86 additions & 0 deletions examples/blog/test/layout/fixed-header-scroll-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* The blog's fixed header opts into the dialog scroll lock's compensation (#1144).
*
* Opening a `<ui-dialog>` locks page scroll, which hides the scrollbar and widens
* the viewport on any engine that ignores `scrollbar-gutter`. The header here is
* `position: fixed`, so it lays out against the viewport and cannot be reached by
* the padding the lock puts on `<html>` to hold in-flow content still. It opts in
* instead, via `--wj-scrollbar-compensation`.
*
* This app owns its copy of the kit's dialog (`components/ui/dialog.ts`, the
* shadcn model), so the header and the lock have to stay in step by hand. That is
* exactly how the app kept the #1144 shift for two review rounds after the kit
* was fixed. The marketing site's copy is a gitignored mirror, so it picked the
* fix up automatically; this one is real tracked source and did not.
*
* The opt-in is a single declaration in the root layout's inline `<style>`, so
* nothing else on the page would notice it going missing, and the shift would
* come back silently. This is what makes that fail instead.
*
* Run: node --test test/layout/fixed-header-scroll-lock.test.ts
*/
import { test, before } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequestHandler } from '@webjsdev/server';

const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const COMPENSATION = '--wj-scrollbar-compensation';

let body: string;

before(async () => {
const app = await createRequestHandler({ appDir: APP_ROOT, dev: false });
await app.warmup?.();
const res = await app.handle(new Request('http://localhost/'));
assert.equal(res.status, 200, 'the home page should render');
body = await res.text();
});

test('the fixed header opts into the scroll-lock compensation', () => {
assert.ok(
body.includes(COMPENSATION),
`the served HTML must reference ${COMPENSATION}, or the fixed header goes back ` +
'to sliding right by half the scrollbar width when a dialog opens',
);
});

test('the opt-in is on the header itself, which is both viewport-width and painting', () => {
// Unlike the marketing site, this app's fixed element IS the painting element,
// so it takes the opt-in directly rather than delegating to a child. Both
// properties matter. Viewport-width, or a left-aligned child (the brand) still
// moves while a centred one looks fine. Painting, or the background stops short
// of the widened edge, since insetting a wrapper insets whatever child paints.
assert.ok(
new RegExp(`\\.site-header\\s*\\{[^}]*var\\(${COMPENSATION}`).test(body),
'a `.site-header` rule consumes the compensation',
);
// Exactly one consumer, or two rules would double-compensate.
const consumers = body.match(new RegExp(`\\{[^{}]*var\\(${COMPENSATION}`, 'g')) ?? [];
assert.equal(
consumers.length,
1,
`exactly one rule may consume the compensation, found ${consumers.length}`,
);
// The element the rule targets has to actually be the fixed one.
assert.ok(
/<header class="site-header fixed /.test(body),
'the .site-header element is the fixed header',
);
});

test('the compensation resolves to zero when no lock is active', () => {
// The `0px` fallback is what keeps the header identical to its unpatched self
// at every other moment. Without it the border width is invalid and the whole
// declaration is dropped, which fails silently rather than visibly.
assert.ok(
body.includes(`var(${COMPENSATION},0px)`) || body.includes(`var(${COMPENSATION}, 0px)`),
'the compensation must carry a 0px fallback for the un-locked case',
);
// A transparent border, so the chrome still paints across the inset.
assert.ok(
new RegExp(`border-right:\\s*var\\(${COMPENSATION},\\s*0px\\)\\s*solid\\s*transparent`).test(body),
'the opt-in is a transparent right border',
);
});
Loading