From 7ea7e6cea726e2cfe7c6dfabd9c9e605b090defc Mon Sep 17 00:00:00 2001 From: Amit Narwal Date: Fri, 23 Oct 2026 11:02:44 +0530 Subject: [PATCH 1/2] Drawing between pixels by default, gliding boxes, and zoomed pages Drawing between pixels is on by default and scrolling boxes glide by default (see CHANGELOG.md). - Svifa chooses the blocks that scroll with the content and gives each a layer of its own, split around fixed elements and z-indexes that reach past a block. Where a layer would change what is drawn over what, it draws on whole pixels and says why once in the console. `data-svifa-subpixel="off"` and `subpixel: false` turn it off. - Scrolling boxes inside the scroller glide with the same feel and hand the wheel on at their end. `nested: 'native'` leaves them to the browser and a function decides for each box. While the page glides, a box's content moves with it exactly. - Zoomed to a part of a pixel (a browser zoom such as 80% or a screen scale such as 125%), the page and its boxes are drawn from where the browser draws the scroll position, so a glide no longer shimmers. The position Svifa reports is unchanged. - Drawn on whole pixels, a glide keeps an even pace to its end. - A glide drawn between pixels never runs past the end of the page, and in Firefox the wheel over a textarea stays with the textarea. - Tests for something only some engines have are tagged and run only there; none is reported skipped. Signed-off-by: Amit Narwal --- CHANGELOG.md | 36 ++ CONTRIBUTING.md | 60 ++- README.md | 177 +++++-- astro/SmoothScroll.astro | 12 +- examples/README.md | 6 +- examples/angular/src/app/app.config.ts | 2 +- examples/angular/src/style.css | 4 - examples/astro/src/layouts/Layout.astro | 2 +- examples/astro/src/styles/style.css | 4 - examples/html/index.html | 8 +- examples/next/app/globals.css | 4 - examples/next/app/layout.tsx | 4 +- examples/nuxt/app/app.vue | 2 +- examples/nuxt/app/assets/style.css | 4 - examples/solid/src/App.tsx | 2 +- examples/solid/src/style.css | 4 - examples/sveltekit/src/routes/+layout.svelte | 2 +- examples/sveltekit/src/style.css | 4 - examples/vite/src/main.ts | 2 +- examples/vite/src/style.css | 4 - examples/vue/src/App.vue | 2 +- examples/vue/src/style.css | 4 - llms.txt | 36 +- playwright.config.ts | 16 +- src/adapter.ts | 16 +- src/pick.ts | 267 ++++++++++ src/react.ts | 2 +- src/scroller.ts | 50 +- src/solid.ts | 2 +- src/subpixel.ts | 510 ++++++++++++++++--- src/svifa.ts | 331 ++++++++++-- src/track.ts | 7 + src/types.ts | 49 +- src/vue.ts | 6 +- src/wheel.ts | 48 +- tests/angular.spec.ts | 6 +- tests/behaviour.spec.ts | 20 +- tests/both.spec.ts | 12 +- tests/choose.spec.ts | 336 ++++++++++++ tests/configure.spec.ts | 31 +- tests/nested.spec.ts | 326 ++++++++++++ tests/properties.spec.ts | 49 +- tests/react.spec.ts | 2 +- tests/solid.spec.ts | 6 +- tests/subpixel.spec.ts | 179 ++++++- tests/svelte.spec.ts | 2 +- tests/vue.spec.ts | 6 +- tests/wheel.spec.ts | 50 +- 48 files changed, 2347 insertions(+), 367 deletions(-) create mode 100644 src/pick.ts create mode 100644 tests/choose.spec.ts create mode 100644 tests/nested.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f6546d2..6e265ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Svifa follows [semantic versioning](https://semver.org). ### Added +- Drawing between pixels is on by default, with no CSS. Svifa chooses the blocks that scroll with the content and gives each a layer + of its own (`will-change: translate`, which it sets for as long as it runs and then puts back), splitting a block around a fixed + element inside it or a `z-index` that reaches past it, and holding sticky elements still while they are held. It chooses once the + page is idle and again when the page changes; an element that becomes fixed inside a block loses that block's layer before the + page is drawn again. Where a layer would change what is drawn over what (a fixed or sticky element with no `z-index`, followed by + content), it draws on whole pixels and says why once in the console. `data-svifa-subpixel="off"` leaves an element on whole + pixels, and `subpixel: false` turns it off. Firefox, which at a screen scale of 100% draws whole pixels whatever it is asked, and + a device with only a touch screen get no layers. +- Scrolling boxes inside the scroller glide the wheel themselves, with the same feel and drawn between pixels, and hand it on at + their end. Boxes found on the page are ready before the wheel first turns over them; a box with a Svifa of its own keeps it. + `nested: 'native'` leaves boxes to the browser, and a function decides for each box, giving one a feel of its own if it returns + options. `configure` passes on to them. While the page glides, a box's content moves with it exactly: the browser moves a box + with a layer of its own only by whole pixels, and Svifa puts back the part of a pixel it leaves out, in every box inside, also + one whose wheel is left to the browser. - `snap`: a scroller with `scroll-snap-type` glides to the snap points its CSS sets. The wheel moves as far as it would without snapping and the glide rests on the nearest snap point; with `mandatory`, one notch or one trackpad swipe moves one point. `scrollTo` and same-page links rest on the snap point nearest their target. `scroll-padding`, `scroll-margin`, @@ -20,8 +34,30 @@ Svifa follows [semantic versioning](https://semver.org). is built with its framework's own tool from the package as it would be published, served as its framework serves it, and driven in a browser, on each change. +### Changed + +- `nested: true`, the default, now glides a scrolling box inside the scroller; it left the box to the browser before. `nested: + 'native'` is the earlier behaviour. +- A page that names its elements in `subpixel` keeps them and no longer needs a CSS rule for them: Svifa sets each one's layer + itself. +- Drawn on whole pixels (with `subpixel: false`, in Firefox, which at a screen scale of 100% draws whole pixels, or where Svifa + cannot draw between pixels), a glide never moves slower than one pixel a sixtieth of a second (60 px a second), at any refresh + rate. Its last pixels come at an even pace instead of ever more slowly, which showed as the page standing still for up to about + 180 ms and then hopping a pixel. On whole pixels a glide therefore ends sooner (one wheel notch at the default `lerp`: about 530 ms + instead of 874 ms), and a timed glide whose easing starts or ends slowly arrives a little before its time. Drawn between pixels, a + glide slows to a stop along its own curve, as before. + ### Fixed +- Zoomed to a part of a pixel (a browser zoom such as 80% or 125%, or a screen scale such as 125%), a page drawn between pixels + shimmered as it glided: the browser draws each scroll position on a whole device pixel, up to half of one off it, and Svifa drew + the rest of the way from the scroll position, not from where the browser drew it. It now draws from there, and the glide ends + where the browser draws the page at rest. The position Svifa reports is unchanged. +- Drawn between pixels, a glide could go up to a pixel past the end of the page: the fraction drawn on the last block made the page + that much longer for the browser. The scroller is now put on the whole pixel just before the place, and the content is only ever + moved toward the start. +- In Firefox, the wheel over a ` +
${'

line

'.repeat(40)}
+
+ ` + +// Opens the test page with restInBox(g, box, delta): turns the wheel over the box, from its top, and returns the frames, at 60 Hz, +// until it rests where the turn sends it, with nothing of its content drawn between pixels any more. +async function open(page: Page, body = '') { + await openPage(page, body) + await page.evaluate(() => { + const w = window as any + w.restInBox = (g: unknown, box: HTMLElement, delta: number) => { + box.scrollTop = 0 + w.frame(g, 60, 2) + w.wheel(box, delta) + const drawn = () => [...box.children].some((child) => (child as HTMLElement).style.translate !== '') + let frames = 0 + while ((box.scrollTop !== delta || drawn()) && frames < 1000) { + w.frame(g) + frames++ + } + return frames + } + }) +} + +test.describe('a scrolling box inside', () => { + test('glides the wheel itself, smoothly, while the page stays where it is, then hands the wheel on at its end', async ({ + page, + }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const g = new w.Svifa({ autoRaf: false }) + const box = document.getElementById('box')! + const first = document.getElementById('first')! + // where the box's content is drawn: where the box is scrolled to, and the part of a pixel drawn on its content + const drawn = () => box.scrollTop - (first.style.translate ? Number.parseFloat(first.style.translate.split(' ')[1]) : 0) + w.frame(g) + const prevented = w.wheel(box, 120) + const path: number[] = [] + for (let i = 0; i < 400 && (box.scrollTop < 120 || first.style.translate !== ''); i++) { + w.frame(g) + path.push(drawn()) + } + // each frame moves less than the one before, as the curve does (on whole pixels, to within the pixel each is drawn on) + const layered = first.style.willChange.includes('translate') + const steps = path.map((at, i) => at - (i ? path[i - 1] : 0)) + const slowing = steps.slice(1, -1).every((step, i) => step >= 0 && step <= steps[i] + (layered ? 1e-6 : 1)) + box.scrollTop = box.scrollHeight + w.frame(g, 60, 2) + const atEnd = w.wheel(box, 100) + return { + prevented, + frames: path.length, + rest: box.scrollTop === box.scrollHeight - box.clientHeight, + slowing, + page: window.scrollY, + atEnd, + target: g.target, + } + }) + expect(result.prevented).toBe(true) + expect(result.frames).toBeGreaterThan(10) + expect(result.slowing).toBe(true) + expect(result.page).toBe(0) + // at its end the box lets the wheel go on: the page glides + expect(result.atEnd).toBe(true) + expect(result.target).toBe(100) + }) + + test('nested: "native" leaves boxes to the browser, and nested: false glides the page over them', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const box = document.getElementById('box')! + const g = new w.Svifa({ autoRaf: false, nested: 'native' }) + const native = w.wheel(box, 100) + g.destroy() + const h = new w.Svifa({ autoRaf: false, nested: false }) + const before = box.scrollTop + const over = w.wheel(box, 100) + w.frame(h, 60, 10) + return { native, over, page: h.target, box: box.scrollTop - before } + }) + // the browser's own scrolling of the box, where a scripted wheel event has one (WebKit), is the browser's: the box is not moved + // once the page glides over it + expect(result).toEqual({ native: false, over: true, page: 100, box: 0 }) + }) + + test('leaves to the browser a box marked data-svifa-native, one the native option picks, form controls and editable text', async ({ + page, + }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false, native: (element: Element) => element.id === 'picked' }) + return ['marked', 'picked', 'text', 'edit'].map((id) => w.wheel(document.getElementById(id), 50)) + }) + expect(result).toEqual([false, false, false, false]) + }) + + test('the nested option decides for each box: the page, the browser, or a feel of its own', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const rest = w.restInBox + const box = document.getElementById('box')! + const decide = { now: true as unknown } + const g = new w.Svifa({ autoRaf: false, nested: () => decide.now }) + w.frame(g) + const plain = rest(g, box, 100) + g.destroy() + const h = new w.Svifa({ autoRaf: false, nested: () => ({ lerp: 0.5 }) }) + w.frame(h) + const quick = rest(h, box, 100) + h.destroy() + decide.now = 'native' + const k = new w.Svifa({ autoRaf: false, nested: () => decide.now }) + const native = w.wheel(box, 100) + k.destroy() + decide.now = false + const m = new w.Svifa({ autoRaf: false, nested: () => decide.now }) + const toPage = w.wheel(box, 100) + return { plain, quick, native, toPage, pageTarget: m.target } + }) + expect(result.quick).toBeLessThan(result.plain) + expect(result).toMatchObject({ native: false, toPage: true, pageTarget: 100 }) + }) + + test('configure changes how the boxes glide too', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const rest = w.restInBox + const box = document.getElementById('box')! + const g = new w.Svifa({ autoRaf: false }) + w.frame(g) + const before = rest(g, box, 100) + g.configure({ lerp: 0.5 }) + const after = rest(g, box, 100) + return { before, after } + }) + expect(result.after).toBeLessThan(result.before) + }) + + test('a box with a Svifa of its own decides; a Svifa made on purpose takes over from one Svifa made; destroy takes them off', async ({ + page, + }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const box = document.getElementById('box')! + const g = new w.Svifa({ autoRaf: false }) + w.wheel(box, 50) + // one made for the box by the page's Svifa gives way, and the box's own decides from then on + let threw = false + let own: any = null + try { + own = new w.Svifa({ autoRaf: false, scroller: box, wheel: false }) + } catch { + threw = true + } + const left = w.wheel(box, 50) + own?.destroy() + g.destroy() + const gone = w.wheel(box, 50) + return { threw, left, gone, classes: box.className } + }) + expect(result).toEqual({ threw: false, left: false, gone: false, classes: '' }) + }) + + test('a box that scrolls sideways glides a sideways turn, and lets an up and down turn go to the page', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const wide = document.getElementById('wide')! + const g = new w.Svifa({ autoRaf: false }) + w.frame(g) + const sideways = w.wheel(wide, 0, { deltaX: 200 }) + w.frame(g, 60, 200) + const down = w.wheel(wide, 100) + return { sideways, left: wide.scrollLeft, down, page: g.target } + }) + expect(result).toEqual({ sideways: true, left: 200, down: true, page: 100 }) + }) +}) + +// Firefox draws whole pixels whatever it is asked, so Svifa draws nothing between them there +test.describe('a scrolling box drawn between pixels', { tag: '@not-firefox' }, () => { + // The browser draws a scrolling box that has a layer of its own where it rests, moving its content by whatever the page around it + // is moved by rounded to a whole device pixel. While the page glides, the box's content carries the rest. + for (const scale of [1, 2]) { + test.describe(`at a device scale of ${scale}`, () => { + test.use({ deviceScaleFactor: scale }) + test('moves the content of every box with the page while the page glides, one the wheel leaves to the browser too, and leaves nothing at rest', async ({ + page, + }) => { + await open( + page, + `
+

text

+

text

+
` + ) + await page.evaluate(() => { + const w = window as any + window.scrollTo({ top: 200, behavior: 'instant' }) + w.g = new w.Svifa({ autoRaf: false }) + }) + await page.waitForFunction(() => document.getElementById('kept')!.style.willChange === 'translate', null, { timeout: 3000 }) + const result = await page.evaluate(() => { + const w = window as any + const y = (element: HTMLElement) => + element.style.translate ? Number.parseFloat(element.style.translate.split(' ')[1]) : 0 + const main = document.getElementById('main')! + const ids = ['first', 'kept'].map((id) => document.getElementById(id)!) + w.frame(w.g) + w.wheel(window, 77.7) + let worst = 0 + let frames = 0 + while (w.g.isGliding && frames < 2000) { + w.frame(w.g, 240) + frames++ + // what the page is drawn by, and what the browser leaves of it on the box: the part over the nearest whole device pixel + const moved = y(main) * devicePixelRatio + const left = (moved - Math.sign(moved) * Math.round(Math.abs(moved))) / devicePixelRatio + for (const element of ids) worst = Math.max(worst, Math.abs(y(element) - left)) + } + return { frames, worst, rest: ids.map((element) => element.style.translate) } + }) + expect(result.frames).toBeGreaterThan(10) + // (to within the six decimals a translate is written to) + expect(result.worst).toBeLessThan(1e-6) + expect(result.rest).toEqual(['', '']) + }) + }) + } + + test('never shows a scroll bar a box does not have while its content moves with the page', async ({ page }) => { + await open( + page, + `
+
${'line
'.repeat(8)}
+
` + ) + await page.evaluate(() => { + const w = window as any + window.scrollTo({ top: 200, behavior: 'instant' }) + w.g = new w.Svifa({ autoRaf: false }) + }) + await page.waitForFunction(() => document.getElementById('pre')!.style.willChange === 'translate', null, { timeout: 3000 }) + const result = await page.evaluate(() => { + const w = window as any + const code = document.getElementById('code')! + // the box's own overflow, as it is written on it + const own = () => [code.style.overflowX, code.style.overflowY].join(' / ') + const style = own() + // the box shows a vertical scroll bar whenever what it holds is taller than it and its overflow lets it scroll that way (the + // test browser hides scroll bars, so they are not measured by their width) + const bar = () => code.scrollHeight > code.clientHeight && /auto|scroll|overlay/.test(getComputedStyle(code).overflowY) + const before = bar() + w.frame(w.g) + w.wheel(window, 77.7) + let shown = 0 + let moved = 0 + while (w.g.isGliding) { + w.frame(w.g, 240) + if (bar()) shown++ + if (document.getElementById('pre')!.style.translate) moved++ + } + return { before, shown, moved, same: own() === style, after: bar() } + }) + expect(result.moved).toBeGreaterThan(10) + expect(result).toMatchObject({ before: false, shown: 0, same: true, after: false }) + }) + + test('is ready before the wheel first turns over it, draws its content between pixels while it glides, and leaves nothing at rest', async ({ + page, + }) => { + await open(page, BOXES) + await page.evaluate(() => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false }) + }) + // the page is chosen once it is idle, and the boxes found inside it with it + await page.waitForFunction(() => document.getElementById('first')!.style.willChange === 'translate', null, { timeout: 3000 }) + const result = await page.evaluate(() => { + const w = window as any + const box = document.getElementById('box')! + const first = document.getElementById('first')! + w.frame(w.g) + w.wheel(box, 77.7) + let drawn = false + let worst = 0 + while (box.scrollTop < 77 || first.style.translate !== '') { + w.frame(w.g, 240) + if (first.style.translate !== '') drawn = true + const y = first.style.translate ? Number.parseFloat(first.style.translate.split(' ')[1]) : 0 + worst = Math.max(worst, y) + if (w.clock > 20000) break + } + const rest = { top: box.scrollTop, translate: first.style.translate } + w.g.destroy() + return { drawn, worst, rest, after: first.getAttribute('style') } + }) + expect(result.drawn).toBe(true) + // only ever moved toward the start + expect(result.worst).toBeLessThanOrEqual(0) + expect(result.rest).toEqual({ top: 78, translate: '' }) + expect(result.after).toBe('margin: 0px;') + }) +}) diff --git a/tests/properties.spec.ts b/tests/properties.spec.ts index e8ce88f..034eb12 100644 --- a/tests/properties.spec.ts +++ b/tests/properties.spec.ts @@ -25,9 +25,10 @@ async function check(page: Page, arbitrary: fc.Arbitrary, run: (value: T) await fc.assert(fc.asyncProperty(arbitrary, run), { numRuns }) } -// The motion's own properties, run at the project's device scale and again at 2, where some browsers keep whole CSS pixels. +// The motion's own properties, run at the project's device scale and again at 2 and at 1.25, where some browsers keep whole CSS +// pixels. function motion() { - test('a glide by lerp follows one curve in time, whatever the frames, and rests on the frame the curve reaches its pixel', async ({ + test('a glide by lerp follows one curve in time, whatever the frames, never slower than a pixel a sixtieth of a second, and rests on the frame the curve reaches its pixel', async ({ page, }) => { await check(page, fc.record({ lerp, from, delta: turn, frames }), async (c) => { @@ -39,22 +40,29 @@ function motion() { const per = Number.isInteger(window.scrollY) ? 1 : devicePixelRatio const start = Math.round(from * limit) window.scrollTo({ top: start, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false, lerp }) + // drawn on whole pixels, where the least pace applies + const g = new w.Svifa({ autoRaf: false, lerp, subpixel: false }) let clock = 1000 g.raf(clock) w.wheel(window, delta) const goal = g.target const rate = lerp * 60 - let elapsed = 0 + // the curve, frame by frame: closing in on the goal by lerp, but never slower than 60 px a second, and never past the goal + let curve = start let off = 0 let wrongFrame = -1 + let slowFrame = -1 let count = 0 for (; g.isGliding && count < 100000; count++) { const ms = frames[count % frames.length] clock += ms - elapsed += ms / 1000 + const before = g.scroll g.raf(clock) - const curve = goal + (start - goal) * Math.exp(-rate * elapsed) + const least = (60 * ms) / 1000 + const closer = goal + (curve - goal) * Math.exp((-rate * ms) / 1000) + curve = curve < goal ? Math.max(closer, Math.min(goal, curve + least)) : Math.min(closer, Math.max(goal, curve - least)) + // every frame moves at least its share of 60 px a second, or the rest of the way + if (Math.abs(g.scroll - before) < Math.min(least, Math.abs(goal - before)) - 1e-9 && slowFrame < 0) slowFrame = count // the curve rests once the browser would draw it on the pixel of its end (a frame that lands on the half pixel between, // to within rounding, may go either way) const away = Math.abs(curve - goal) * per @@ -63,11 +71,12 @@ function motion() { } const rest = { scroll: g.scroll, y: window.scrollY, gliding: g.isGliding } g.destroy() - return { start, limit, goal, off, wrongFrame, rest } + return { start, limit, goal, off, wrongFrame, slowFrame, rest } }, c) expect(run.goal).toBe(Math.min(Math.max(run.start + c.delta, 0), run.limit)) expect(run.off).toBeLessThan(1e-6) expect(run.wrongFrame).toBe(-1) + expect(run.slowFrame).toBe(-1) expect(run.rest).toEqual({ scroll: run.goal, y: run.goal, gliding: false }) }) }) @@ -183,7 +192,7 @@ function motion() { }) }) - test('a timed glide follows its curve over its time, arrives on the frame its time runs out, and resolves true', async ({ + test('a timed glide follows its curve, never slower than a pixel a sixtieth of a second, arrives by the frame its time runs out, and resolves true', async ({ page, }) => { const cases = fc.record({ @@ -207,21 +216,31 @@ function motion() { const start = Math.round(from * limit) const goal = Math.min(Math.max(to, 0), limit) window.scrollTo({ top: start, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false }) + const g = new w.Svifa({ autoRaf: false, subpixel: false }) let clock = 1000 g.raf(clock) const arrived = g.scrollTo(to, { duration, easing }) let elapsed = 0 let off = 0 let count = 0 - let due = -1 // the frame on which the time runs out + // the curve, frame by frame: the easing over its time, but never slower than 60 px a second toward the goal and never past it + // by that rule; it arrives once its time runs out or it reaches the goal, whichever comes first + let model = start + let due = -1 + const way = Math.sign(goal - start) for (; g.isGliding && count < 100000; count++) { const ms = frames[count % frames.length] clock += ms elapsed += ms / 1000 - if (due < 0 && elapsed / duration >= 1) due = count g.raf(clock) - if (g.isGliding) off = Math.max(off, Math.abs(g.scroll - (start + (goal - start) * easing(elapsed / duration)))) + const t = Math.min(1, elapsed / duration) + const eased = t >= 1 ? goal : start + (goal - start) * easing(t) + const least = (60 * ms) / 1000 + model = way > 0 ? Math.max(eased, Math.min(goal, model + least)) : Math.min(eased, Math.max(goal, model - least)) + // short of the goal by no more than rounding is at the goal + if (Math.abs(goal - model) < 1e-9) model = goal + if (due < 0 && (t >= 1 || model === goal)) due = count + if (g.isGliding) off = Math.max(off, Math.abs(g.scroll - model)) } const result = { goal, start, off, last: count - 1, due, resolved: await arrived, scroll: g.scroll, y: window.scrollY } g.destroy() @@ -244,6 +263,12 @@ test.describe('properties', () => { motion() }) + // a part of a device pixel per CSS pixel, as on a laptop set to 125%, where browsers keep whole CSS pixels + test.describe('at a device scale of 1.25', () => { + test.use({ deviceScaleFactor: 1.25 }) + motion() + }) + test('options of any kind leave every setting usable, and the scroller always inside the page', async ({ page }) => { const anything = fc.oneof( fc.double(), diff --git a/tests/react.spec.ts b/tests/react.spec.ts index a07aed4..1bfafb4 100644 --- a/tests/react.spec.ts +++ b/tests/react.spec.ts @@ -75,7 +75,7 @@ test('SmoothScroll makes one Svifa (StrictMode included), retunes it when a prop const prevented = w.wheel(window, 200) for (let t = 1016; w.made.isGliding; t += 16) w.made.raf(t) return { - classes: document.documentElement.className, + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), prevented, y: window.scrollY, reports: w.reports > 0, diff --git a/tests/solid.spec.ts b/tests/solid.spec.ts index cf35ebd..432fd0d 100644 --- a/tests/solid.spec.ts +++ b/tests/solid.spec.ts @@ -61,7 +61,11 @@ test('SmoothScroll makes one Svifa, retunes it, makes it again when what it scro const w = window as any w.made = w.probe const prevented = w.wheel(window, 200) - return { classes: document.documentElement.className, prevented, lerp: w.made.options.lerp } + return { + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), + prevented, + lerp: w.made.options.lerp, + } }) expect(first).toEqual({ classes: 'svifa svifa-gliding', prevented: true, lerp: 0.1 }) await glide(page) diff --git a/tests/subpixel.spec.ts b/tests/subpixel.spec.ts index d73cc41..1cc846d 100644 --- a/tests/subpixel.spec.ts +++ b/tests/subpixel.spec.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 Arcwright Studio +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { expect, test } from './fixtures' import { open } from './setup' @@ -11,7 +13,8 @@ const PAGE = `
` -const STYLE = '.move, .stick { will-change: translate; }' +// no CSS of its own: Svifa gives each element its layer +const STYLE = '' // the vertical translate an element carries, in CSS pixels (0 when it has none) const SHIFT = `(element) => { const t = element.style.translate; return t ? Number.parseFloat(t.split(' ')[1]) : 0 }` @@ -240,23 +243,25 @@ test('draws a sticky element exactly in every frame as it reaches its offset, ev }) test.describe('the browser’s own grid', () => { - test('never leaves more than half a pixel to draw, in a browser that cuts positions down as in one that rounds them', async ({ - page, - }) => { + test('never puts the scroller past where the content is drawn, and leaves less than a pixel to draw', async ({ page }) => { await open(page, PAGE, STYLE) - const worst = await page.evaluate(() => { + const result = await page.evaluate(() => { const w = window as any const g = new w.Svifa({ autoRaf: false, subpixel: '.move' }) w.frame(g) w.wheel(window, 263.9) - let worst = 0 + let ahead = 0 + let behind = 0 while (g.isGliding) { w.frame(g, 144) - worst = Math.max(worst, Math.abs(window.scrollY - g.scroll)) + ahead = Math.max(ahead, window.scrollY - g.scroll) + behind = Math.max(behind, g.scroll - window.scrollY) } - return worst + return { ahead, behind } }) - expect(worst).toBeLessThanOrEqual(0.5 + 1e-6) + // the content is only ever moved toward the start (moved toward its end it would make the page longer) + expect(result.ahead).toBeLessThanOrEqual(1e-6) + expect(result.behind).toBeLessThan(1) }) test('without drawing between pixels, rests on the nearest pixel to where it was sent', async ({ page }) => { @@ -288,13 +293,14 @@ test.describe('the browser’s own grid', () => { w.frame(g) g.scrollTo(200.3) let last = 0 + // at 1000 Hz a frame's share of the least pace (60 px a second) is 0.06 px, so a jump onto the grid at the end would show while (g.isGliding) { last = window.scrollY - shift() - w.frame(g, 120) + w.frame(g, 1000) } return { last, rest: window.scrollY, value: g.scroll, left: one.style.translate } }) - expect(Math.abs(result.last - result.rest)).toBeLessThan(0.02) + expect(Math.abs(result.last - result.rest)).toBeLessThan(0.07) expect(result.value).toBe(result.rest) expect(result.left).toBe('') }) @@ -314,19 +320,22 @@ test.describe('the browser’s own grid', () => { [limit - 1, -1], ].map(([start, delta]) => { window.scrollTo({ top: start, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false }) + // drawn on whole pixels: where the scroller is, is where the content is drawn + const g = new w.Svifa({ autoRaf: false, subpixel: false }) w.frame(g) w.wheel(window, delta) let step = 0 let drawn = 0 + // at 240 Hz a frame's share of the least pace is a quarter of a pixel, well under the step a jump would make while (g.isGliding) { const before = g.scroll - w.frame(g) + w.frame(g, 240) step = Math.max(step, Math.abs(g.scroll - before)) drawn = Math.max(drawn, Math.abs(window.scrollY - g.scroll)) } - // arriving on a whole pixel settles the last half of one at once, with the frame's own share of the curve - const run = { aimed: start + delta, rest: g.scroll, y: window.scrollY, smooth: step < 0.6, drawn: drawn <= 0.5 } + // arriving on a whole pixel settles the last half of one at once, with the frame's own share of the curve or of the least + // pace (a quarter of a pixel at 240 Hz), whichever is more + const run = { aimed: start + delta, rest: g.scroll, y: window.scrollY, smooth: step <= 0.75 + 1e-9, drawn: drawn <= 0.5 } g.destroy() return run }) @@ -338,7 +347,8 @@ test.describe('the browser’s own grid', () => { await open(page) const worst = await page.evaluate(() => { const w = window as any - const g = new w.Svifa({ autoRaf: false }) + // drawn on whole pixels: where the scroller is, is where the content is drawn + const g = new w.Svifa({ autoRaf: false, subpixel: false }) w.frame(g) w.wheel(window, 7) let worst = 0 @@ -399,7 +409,7 @@ test.describe('the browser’s own grid', () => { [0.1, 16, 5], ]) { window.scrollTo({ top: 300, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false, lerp }) + const g = new w.Svifa({ autoRaf: false, lerp, subpixel: false }) let clock = 1000 g.raf(clock) w.wheel(window, delta) @@ -418,6 +428,116 @@ test.describe('the browser’s own grid', () => { }) }) +// Zoomed to a part of a pixel, a browser keeps the scroller on a grid of whole CSS pixels (a device scale, as Playwright sets it) or of +// device pixels (Chrome at the browser's own zoom, Ctrl and minus), and draws each place on a whole device pixel, up to half of one +// off it (Chrome works it out in single precision and rounds half up). Drawn between pixels, the content is drawn from where the +// browser draws the scroller: the place it is drawn at moves on with the glide and never back, stays within half a device pixel of +// the glide's place (which is what Svifa reports), and ends where the browser draws the scroller at rest, with nothing left to draw. +// Whole turns, so on whole CSS pixels each glide rests where it was sent; on device pixels, on the one nearest. +function zoomedGlides({ shiftSource, device }: { shiftSource: string; device: boolean }) { + const w = window as any + const shift = new Function(`return ${shiftSource}`)() as (element: HTMLElement) => number + const one = document.getElementById('one')! + const limit = document.documentElement.scrollHeight - innerHeight + const ratio = devicePixelRatio + const drawnAt = (place: number) => Math.round(Math.fround(place * ratio)) / ratio + const runs: Record[] = [] + for (const [start, delta, hz] of [ + [0, 137, 240], + [301, 23, 1000], + [302, 61, 240], + [903, -61, 1000], + [limit - 3, 9, 240], + ]) { + window.scrollTo({ top: start, behavior: 'instant' }) + const g = new w.Svifa({ autoRaf: false, subpixel: '.move' }) + w.frame(g) + w.wheel(window, delta) + const goal = g.target + const rest = device ? Math.min(Math.round(goal * ratio), Math.floor(limit * ratio)) / ratio : goal + const way = Math.sign(goal - window.scrollY) + let drawn = drawnAt(window.scrollY) + let back = 0 + let off = 0 + let toward = 0 + let reported = 0 + let step = 0 + while (g.isGliding) { + const before = g.scroll + w.frame(g, hz) + if (!g.isGliding) break + const now = drawnAt(window.scrollY) - shift(one) + back = Math.max(back, (drawn - now) * way) + off = Math.max(off, Math.abs(now - g.scroll)) + toward = Math.max(toward, shift(one)) + if (!(g.scroll >= 0 && g.scroll <= limit) || (g.scroll - before) * way < -1e-9) reported++ + step = Math.abs(now - drawn) + drawn = now + } + runs.push({ + start, + delta, + back: back <= 1e-6, + off: off <= 0.5 / ratio + 1e-6, + toward: toward <= 1e-6, + reported, + // read back in single precision by some browsers, and the device pixel ratio is single precision too + rest: g.scroll === window.scrollY && Math.abs(window.scrollY - rest) < 1e-3, + // the last move, onto where the browser draws it at rest, is no bigger than the glide's own last step (in Firefox the least + // pace, a quarter of a pixel at 240 Hz) or a tiny fraction; Firefox keeps a translate to a sixtieth of a CSS pixel + landed: Math.abs(drawnAt(window.scrollY) - drawn) <= Math.max(step, 0.07) + 1 / 60, + left: one.style.translate, + }) + g.destroy() + } + return runs +} + +const ZOOMED = { back: true, off: true, toward: true, reported: 0, rest: true, landed: true, left: '' } + +for (const scale of [0.8, 0.9, 1.25, 1.5]) { + test.describe(`zoomed to ${scale * 100}%`, () => { + test.use({ deviceScaleFactor: scale }) + test('the content is drawn moving on with the glide from where the browser draws the scroller, and lands where it rests', async ({ + page, + }) => { + await open(page, PAGE, STYLE) + const runs = await page.evaluate(zoomedGlides, { shiftSource: SHIFT, device: false }) + for (const run of runs) expect(run).toEqual({ start: run.start, delta: run.delta, ...ZOOMED }) + }) + }) +} + +// The browser's own zoom is not a device scale: Chrome keeps the scroller on device pixels at every zoom (at 80% every 1.25 CSS +// pixels). Chromium's full build takes a zoom level from its profile, as a person's Chrome keeps one. +test.describe('zoomed by the browser', () => { + for (const zoom of [0.8, 1.25]) { + test( + `at ${zoom * 100}% the content is drawn moving on with the glide, from device pixels, and lands where it rests`, + { tag: '@chromium-only' }, + async ({ playwright }, testInfo) => { + const profile = testInfo.outputPath('profile') + mkdirSync(join(profile, 'Default'), { recursive: true }) + const level = Math.log(zoom) / Math.log(1.2) + writeFileSync(join(profile, 'Default', 'Preferences'), JSON.stringify({ partition: { default_zoom_level: { x: level } } })) + const context = await playwright.chromium.launchPersistentContext(profile, { + channel: 'chromium', + viewport: { width: 1280, height: 800 }, + }) + try { + const page = context.pages()[0] ?? (await context.newPage()) + await open(page, PAGE, STYLE) + expect(await page.evaluate(() => devicePixelRatio)).toBeCloseTo(zoom, 5) + const runs = await page.evaluate(zoomedGlides, { shiftSource: SHIFT, device: true }) + for (const run of runs) expect(run).toEqual({ start: run.start, delta: run.delta, ...ZOOMED }) + } finally { + await context.close() + } + } + ) + } +}) + test('holds an element stuck to the bottom still while the content glides past it', async ({ page }) => { await open( page, @@ -536,3 +656,28 @@ test('is left off for a horizontal scroller in right-to-left content', async ({ }) expect(cls).toBe('svifa') }) + +test('drawn between pixels a glide slows to a stop along its curve; in Firefox, which draws whole pixels, it keeps the least pace', async ({ + page, + browserName, +}) => { + await open(page, PAGE, STYLE) + const slowest = await page.evaluate(() => { + const w = window as any + const g = new w.Svifa({ autoRaf: false, subpixel: '.move' }) + w.frame(g) + w.wheel(window, 100) + // the least a frame moved while the glide was under way, at 60 Hz + let least = Number.POSITIVE_INFINITY + while (g.isGliding) { + const before = g.scroll + w.frame(g, 60) + if (g.isGliding) least = Math.min(least, Math.abs(g.scroll - before)) + } + g.destroy() + return least + }) + // the least pace is a pixel a frame at 60 Hz; along the curve, the last frames move a small part of one + if (browserName === 'firefox') expect(slowest).toBeGreaterThanOrEqual(1 - 1e-9) + else expect(slowest).toBeLessThan(0.1) +}) diff --git a/tests/svelte.spec.ts b/tests/svelte.spec.ts index 2517226..0264675 100644 --- a/tests/svelte.spec.ts +++ b/tests/svelte.spec.ts @@ -92,7 +92,7 @@ test('smoothScroll makes one Svifa that components inside reach, and takes it of const prevented = w.wheel(window, 200) return { same: shared === w.probe, - classes: document.documentElement.className, + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), prevented, lerp: w.probe.options.lerp, } diff --git a/tests/vue.spec.ts b/tests/vue.spec.ts index 46ff652..507e2a4 100644 --- a/tests/vue.spec.ts +++ b/tests/vue.spec.ts @@ -67,7 +67,11 @@ test('SmoothScroll makes one Svifa, retunes it, makes it again when what it scro const w = window as any w.made = w.probe const prevented = w.wheel(window, 200) - return { classes: document.documentElement.className, prevented, lerp: w.made.options.lerp } + return { + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), + prevented, + lerp: w.made.options.lerp, + } }) expect(first).toEqual({ classes: 'svifa svifa-gliding', prevented: true, lerp: 0.1 }) await glide(page) diff --git a/tests/wheel.spec.ts b/tests/wheel.spec.ts index 6ff127e..6834c90 100644 --- a/tests/wheel.spec.ts +++ b/tests/wheel.spec.ts @@ -61,6 +61,36 @@ test.describe('the wheel', () => { } }) + test('on whole pixels the page never stands still for more than a sixtieth of a second before it rests, at any refresh rate', async ({ + page, + }) => { + await open(page) + const runs = await page.evaluate(() => { + const w = window as any + return [60, 120, 240, 500].map((hz) => { + window.scrollTo({ top: 0, behavior: 'instant' }) + const g = new w.Svifa({ autoRaf: false, subpixel: false }) + w.frame(g, hz) + w.wheel(window, 100) + // the longest run of frames the page is drawn in the same place while the glide is under way + let still = 0 + let longest = 0 + let drawn = window.scrollY + while (g.isGliding) { + w.frame(g, hz) + if (window.scrollY === drawn) longest = Math.max(longest, ++still) + else still = 0 + drawn = window.scrollY + } + g.destroy() + return { hz, ms: (longest * 1000) / hz, rest: window.scrollY } + }) + }) + // a sixtieth of a second, and the frame that shows the move + for (const run of runs) expect(run).toEqual({ hz: run.hz, ms: expect.any(Number), rest: 100 }) + for (const run of runs) expect(run.ms).toBeLessThanOrEqual(1000 / 60 + 1000 / run.hz + 1e-6) + }) + test('adds up notches that come while a glide is under way', async ({ page }) => { await open(page) const target = await page.evaluate(() => { @@ -159,11 +189,11 @@ test.describe('the wheel', () => { expect(prevented).toBe(false) }) - test('glides over a set time when given a duration', async ({ page }) => { + test('glides over a set time when given a duration, never slower than a pixel a sixtieth of a second', async ({ page }) => { await open(page) const frames = await page.evaluate(() => { const w = window as any - w.g = new w.Svifa({ autoRaf: false, duration: 0.5 }) + w.g = new w.Svifa({ autoRaf: false, duration: 0.5, subpixel: false }) w.frame(w.g) w.wheel(window, 300) let count = 0 @@ -171,11 +201,21 @@ test.describe('the wheel', () => { w.frame(w.g, 60) count++ } - return { count, at: window.scrollY } + // the easing over half a second, held to a pixel a frame at 60 Hz where it would go slower: the frame it reaches 300 px + let model = 0 + let expected = 0 + // (short of it by no more than rounding is there) + for (let t = 1 / 60; model < 300 - 1e-9; t += 1 / 60) { + const eased = t >= 0.5 ? 300 : (300 * (1 - 2 ** (-20 * t))) / (1 - 2 ** -10) + model = Math.max(eased, Math.min(300, model + 1)) + expected++ + } + return { count, expected, at: window.scrollY } }) expect(frames.at).toBe(300) - expect(frames.count).toBeGreaterThanOrEqual(29) - expect(frames.count).toBeLessThanOrEqual(31) + // its time is 30 frames; held to the pace at the slow end of the easing, it arrives before that + expect(frames.count).toBe(frames.expected) + expect(frames.count).toBeLessThan(30) }) }) From 836f45781c44c96c5e234881310c370aff761af3 Mon Sep 17 00:00:00 2001 From: Amit Narwal Date: Fri, 23 Oct 2026 12:42:08 +0530 Subject: [PATCH 2/2] A measured comparison, and two edges at a scale of a part of a pixel - The README compares Svifa with the most used smooth scroll libraries, measured on the same page: the end of a glide recorded from a 254 Hz screen in Chrome, Edge, Firefox and WebKit, and what each does with sticky elements, find in page, scroll-driven animations, scrolling boxes, snapping, same-page links, reduced motion, the keyboard, size and dependencies. - Before the browser shows which places it keeps, a glide drawn between pixels at 125% could report a position just past where it was sent; once the browser shows it keeps whole CSS pixels, a glide already past the place it then aims at is held there. - Drawn on whole pixels at such a scale, a glide could take its last pixel early; until the browser shows its grid, it rests only once it is on its last pixel on both grids. - The property tests also run at a device scale of 1.25, and each case they found is a test of its own. Signed-off-by: Amit Narwal --- CHANGELOG.md | 8 ++++- README.md | 35 +++++++++++++++++-- examples/html/index.html | 2 +- src/scroller.ts | 16 +++++++-- src/svifa.ts | 11 ++++-- tests/subpixel.spec.ts | 72 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e265ce..6a0f60a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,12 @@ Svifa follows [semantic versioning](https://semver.org). shimmered as it glided: the browser draws each scroll position on a whole device pixel, up to half of one off it, and Svifa drew the rest of the way from the scroll position, not from where the browser drew it. It now draws from there, and the glide ends where the browser draws the page at rest. The position Svifa reports is unchanged. +- At a device scale of a part of a pixel, a glide drawn between pixels could report a position up to half a device pixel past + where it was sent, before the browser had shown which places it keeps (at 125%, 6.4 for a glide sent to 6). A glide already past + the place it then aims at is held there. +- Drawn on whole pixels at a device scale of a part of a pixel, a glide could take its last pixel early (at 125%, a glide sent to 1 + rested while its curve was at 0.42, since on the device grid both are 0.8). Until the browser shows which places it keeps, a glide + rests only once it is on its last pixel on both grids. - Drawn between pixels, a glide could go up to a pixel past the end of the page: the fraction drawn on the last block made the page that much longer for the browser. The scroller is now put on the whole pixel just before the place, and the content is only ever moved toward the start. @@ -63,7 +69,7 @@ Svifa follows [semantic versioning](https://semver.org). keeps its name, and each framework entry imports the core from the file beside it. - React: a prop taken off `SmoothScroll` (left out of its props) now goes back to its default; it kept the value it had. - The README gave the size as under 8 kB minified and gzipped, which 1.1.0 already passed (8.6 kB). It now gives the size as - measured, and a test keeps it true. + measured, and a test keeps it true. It also compares Svifa with the most used smooth scroll libraries, as measured. ## 1.1.0 diff --git a/README.md b/README.md index d66aa3c..1dbc123 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,37 @@ Every claim here is checked by the tests, which run in Chromium, Firefox and Web the motion was measured frame by frame on a 254 Hz screen. If a site already uses a smooth scroll library, switching is usually two lines: the import and the constructor. +## How it compares + +Measured on 23 October 2026 with the current release of each library, each set up on the same page as its own documentation +shows. The end of a glide was recorded on a 254 Hz screen at a scale of 100%: one wheel notch sent through Windows' own input, +every frame the monitor showed captured, and the drawn position of a 1 px line read to a hundredth of a pixel, twice for each +library and browser, giving the longer of the two. Chrome 155, Edge 155 and Firefox 158 are the browsers as installed; WebKit +27.2 is Playwright's build of Safari's engine for Windows, not Safari. The other rows come from the same pages driven in Chromium, +Firefox and WebKit. Sizes are each page's setup bundled and minified with esbuild, JavaScript and CSS gzipped (so Svifa's figure +includes its CSS file and differs a little from its script build's). + +| | Svifa 1.2.0 | Lenis 1.3.27 | Locomotive Scroll 5.0.1 | GSAP ScrollSmoother 3.15.0 | Smooth Scrollbar 8.8.4 | +| ----------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- | +| Longest the page stands still near the end of a glide: Chrome, Edge, Firefox, WebKit | 12, 20, 20, 20 ms | 181, 181, 181, 122 ms | 181, 181, 177, 122 ms | 24, 24, 110, 16 ms | 4, 8, 4, 20 ms, and the whole glide lasts 110 to 140 ms (425 ms in WebKit) | +| Drawn between pixels (Chrome, Edge, WebKit) | Yes | No | No | Yes | No (yes with `renderByPixels: false`) | +| The same glide at 60, 120 and 240 Hz | Yes | Yes | Yes | Yes | No: it rests after 467, 233 and 117 ms | +| The browser's own scroll position | Kept | Kept | Kept | It jumps ahead, and the content follows by a transform | Stays at 0: it scrolls a container of its own | +| `position: sticky` | Works | Works | Works | Does not stick | Does not stick | +| Find in page shows the match | Yes | Yes | Yes | No | No | +| Scroll-driven animations follow the page (Chromium) | Yes | Yes | Yes | Up to 780 px behind | Do not move | +| The wheel over a scrolling box inside the page | The box glides | The page scrolls; with `allowNestedScroll`, the box scrolls the browser's way | The page scrolls | The box scrolls the browser's way | The page scrolls | +| CSS scroll snapping, one wheel notch | Glides to the next snap point | Left to the browser, which pulls it back (snap points of its own in `lenis/snap`) | Left to the browser, which pulls it back | Snap points ignored | Snap points ignored | +| Same-page links | Glide, with an offset | Glide, with an offset | Glide, with an offset | Jump | The address changes, the page stays | +| A reader who asks for reduced motion | No glide | No smoothing | Glides | Glides | Glides | +| Space and Page Down | The browser's own | The browser's own | The browser's own | The browser's own | Its own (Space moves 200 px) | +| Markup it needs | None | None | None | Two wrapper elements around the page | A container of fixed height | +| Components | React, Vue, Svelte, Solid, Angular, Astro | React, Vue, Nuxt | None | None | None | +| Size on a page, minified and gzipped | 15.6 kB | 5.7 kB | 9.1 kB | 50.0 kB | 25.1 kB | +| Runtime dependencies | None | None | Lenis 1.3.17 | None | core-js, tslib | +| License | MIT | MIT | MIT | GSAP's own no-charge license | MIT | +| Latest release | This one | 15 October 2026 | 15 January 2026 | 13 April 2026 | 5 June 2023 | + ## Try it Each example opens on StackBlitz, ready to run and change: @@ -99,7 +130,7 @@ Each installs and imports it as published, Yarn's Plug'n'Play mode included. Or load the script build, which sets `window.Svifa`: ```html - + ``` Pin the exact version in the address, as here: the `integrity` hash then makes the browser refuse any file that differs from the @@ -395,7 +426,7 @@ each page as it comes in. A script reaches the page's Svifa through the hidden e ```html - + diff --git a/examples/html/index.html b/examples/html/index.html index 7e13c54..75120b6 100644 --- a/examples/html/index.html +++ b/examples/html/index.html @@ -118,7 +118,7 @@

Between pixels

Scroll slowly and watch the text: it moves evenly to the end, with no one-pixel hops as it settles.

- +