8m left

Safari dropped my scroll because of a CSS fade on an ancestor

On my iPhone the rendered pages in ReadKinetic would not scroll. I dragged my finger and nothing happened. Maybe two drags in ten actually did anything. The spots that worked? I could never find them twice in the same place. On Android Chrome it was perfect. I lost three days to this. Three entire days staring at a frozen page.

Two fixes that were right and did not work

A non-passive touchmove listener anywhere above a scroller takes it off the accelerated path. The browser can no longer scroll on the compositor because the handler might still call preventDefault. Every frame waits on JavaScript. Chrome absorbs that. Safari just gets annoyed and drops the scroll. I fixed it. Still broken.

Then I found #root/.app-mode { touch-action: none }. touch-action resolves by walking from your touched element up through every ancestor. One none on that path forbids the pan no matter what the scroller asks for. The shell is overflow:hidden and has nothing of its own to scroll, so it was forbidding a gesture it could not perform. I fixed that too. Still broken.

The fade was the bug

The reading surface had active:brightness-95 and transition-all. A CSS filter animating on touch, on an ancestor of the scroller.

A filter gives an element its own compositing layer. You start animating that filter the exact millisecond a finger lands, right above a scrolling box, and Safari decides to rebuild those layers mid-gesture. It drops the scroll it had already begun.

The sentence that solved it was my own bug report: “the page fades a bit like as if I am choosing it, and then it will not slide.” The fade was not a symptom next to the bug. The fade WAS the bug. The drags that worked were just touches that happened not to trigger :active. That is why the working spot could never be found twice.

I shipped the fixes across three deploys. Commit e29359c relaxed the shell from touch-action: none to pan-y and gave every inner scroller pan-y too. Still broken. Commit 85f5d0d detached the non-passive listeners over the PDF surface. Still broken. Commit b5ff883 took the filter off that surface and dropped a legacy -webkit-overflow-scrolling: touch. Fixed. That isolation matters. Removing the filter is what fixed it.

Minimal reproduction · drag the box on an iPhone

The filter sits on the box's parent, not on the scroller. Drag with the switch on, then again with it off, and compare the counts. A desktop browser shows no difference — that is expected.

01 the quick brown fox jumps over the lazy dog
02 the quick brown fox jumps over the lazy dog
03 the quick brown fox jumps over the lazy dog
04 the quick brown fox jumps over the lazy dog
05 the quick brown fox jumps over the lazy dog
06 the quick brown fox jumps over the lazy dog
07 the quick brown fox jumps over the lazy dog
08 the quick brown fox jumps over the lazy dog
09 the quick brown fox jumps over the lazy dog
10 the quick brown fox jumps over the lazy dog
11 the quick brown fox jumps over the lazy dog
12 the quick brown fox jumps over the lazy dog
13 the quick brown fox jumps over the lazy dog
14 the quick brown fox jumps over the lazy dog
15 the quick brown fox jumps over the lazy dog
16 the quick brown fox jumps over the lazy dog
17 the quick brown fox jumps over the lazy dog
18 the quick brown fox jumps over the lazy dog
19 the quick brown fox jumps over the lazy dog
20 the quick brown fox jumps over the lazy dog
21 the quick brown fox jumps over the lazy dog
22 the quick brown fox jumps over the lazy dog
23 the quick brown fox jumps over the lazy dog
24 the quick brown fox jumps over the lazy dog
25 the quick brown fox jumps over the lazy dog
26 the quick brown fox jumps over the lazy dog
27 the quick brown fox jumps over the lazy dog
28 the quick brown fox jumps over the lazy dog
29 the quick brown fox jumps over the lazy dog
30 the quick brown fox jumps over the lazy dog
31 the quick brown fox jumps over the lazy dog
32 the quick brown fox jumps over the lazy dog
33 the quick brown fox jumps over the lazy dog
34 the quick brown fox jumps over the lazy dog
35 the quick brown fox jumps over the lazy dog
36 the quick brown fox jumps over the lazy dog
37 the quick brown fox jumps over the lazy dog
38 the quick brown fox jumps over the lazy dog
39 the quick brown fox jumps over the lazy dog
40 the quick brown fox jumps over the lazy dog

This gesture

0

Best with filter

Best without

Gestures

0

Scroll events fired by one touch, start to finish. Tested on iOS 26.6.

On my iPhone 16 Pro running iOS 26.6, with the filter off, every drag scrolled the box. With the filter on I had to try up to 25 times before one drag took. That is the part that surprised me. It is not a slower scroll or a janky one. Most of the time there is no scroll at all, and then one works for no reason you can see. Note that this is the box that fails, not the page around it, which scrolls fine throughout.

A bare version of that box, with no framework around it, is at /repro/safari-filter-scroll.html.

Filed as WebKit bug 322849, under Compositing. Simon Fraser from the WebKit team answered the same day:

I believe this happens when we remove and re-add a UIScrollView to the view hierarchy as ancestor layers change.

That explains the part I could not. On iOS the browser does not scroll an overflow area itself. It hands the job to a real UIScrollView. Change the compositing layers above it and that view gets detached and put back, and a UIScrollView that disappears in the middle of a gesture takes the touch it was tracking with it.

So it is not a slow scroll or a dropped frame. For a moment the thing doing the scrolling does not exist. That is also why one drag in twenty five works. It depends on whether the view happened to survive that particular gesture.

The bug is open and unassigned. No fix, no timeline. But I know what I was looking at now.

The harder problem underneath

Honestly the Safari mess was secondary. Scrolling a page means absolutely nothing if the page does not know which words it holds. Everything in this reader is a word index. Your place, your marks, find, the chapters, the seek rail. A page image drawn beside the wrong words breaks the whole app. You tap a line and go somewhere else. Find highlights the wrong page. The rail lies.

Counting the words does not work

The obvious approach is walking the pages and keeping a running total. It drifts. The finished text splits differently from the way the counter assumed. A word broken across a line as “men-tion” gets rejoined into one word after the counter has already counted two. The error accumulates. The first page is right and the ninetieth is a paragraph out.

Warming up

Pages landing correctly

3 / 4

Page 1 · titlestarts at word 0

The Adventures of Sherlock Holmes…”

Page 2 · contentsstarts at word 9

Contents I. A Scandal in…”

Page 3 · the first story opensstarts at word 24

I. A SCANDAL IN BOHEMIA…”

Page 4 · dialoguestarts at word 50 · should be 49

suits you,” he remarked. “Seven!”…”

Counting drifts. Page three carries a word broken across a line — “men-tion” — which the importer rejoins into one word and the counter has already counted as two. Every page after it inherits the error, and the error grows.

So the pages are found, not counted

I join pages with a blank line. This makes each page’s opening words the opening of a paragraph. The reader already computes paragraphs. I match anchors against paragraph starts, searching forward from wherever the last page landed. Every match is local and cheap.

Then three things broke.

A page can open on a one-word paragraph

Every story opens “I. / A SCANDAL IN BOHEMIA / I. / To Sherlock Holmes she is always the woman.” The first paragraph is the single word “I.”

That can never match a four-word anchor. The page that begins a story never matched. Because each search starts where the last one landed, the failures compound. Page 41 of 306 reported it began at word 16.

Stranded after dialogue

The forward search looked twelve paragraphs ahead. I reasoned no page makes more than twelve. Two people talking makes thirty one-line paragraphs without trying. The page after a conversation was out of range before it was ever looked for.

What happens when a chapter matches the table of contents?

The first story page opens “I. A SCANDAL IN BOHEMIA”. The contents page twenty words earlier reads “I. A Scandal in Bohemia / II. The Red-Headed League”. The first four words are the same words.

So the story page matched its own entry in the contents, at word ten. The contents page was left spanning 1 word with 74 hit boxes stacked on it. It looked like the entire page highlighting at once when you tapped anything on it.

I changed the comparison to 8 opening words. They diverge at the sixth. I expanded the window to 64. Now 304 of 304 pages with a usable anchor land exactly on their own opening words.

One box per word, not one per line

pdf.js returns a text item per run. A run is usually a whole line. One hit box over that means tapping anywhere on a line goes to the line’s first word. It reads like the page picked a paragraph instead of the word under your finger.

The file does not say where the words inside a run are. I mount a canvas to measure each word’s offset within the run, scaled so the total matches the width the file reports.

Then each word is matched to a book index with its neighbours confirmed. A page holds “the” thirty times. A word is identified by its spelling and its neighbours’ spelling, never by spelling alone.

Measured across four pages spread through the book: 370/370, 352/352, 374/374 and 296/296 boxes on consecutive indices. No duplicates. Nothing out of order.

Letterspaced titles

Letterspaced titles arriving as “T h e” are repaired per line, but only when ≥4 tokens and ≥80% are lone letters. A page of set theory is full of lone = and ∅, and welding those to their operands would break a book to fix a title. It changes exactly two lines in Sherlock.

The final cost

All parsing, rendering and storage happens client-side with no server. I use three IndexedDB stores (book_metadata, book_content, book_source) because figures living in metadata made the shelf load 8.2 MB per book.

A 1,390-page PDF opens and scrolls. Canvases mounted at any moment: 4 to 6, not 1,390. Pages are memoised. currentIndex is handed only to the page that holds it. The text layer is one hit surface per page with coordinate hit-testing, instead of roughly 1,700 absolutely positioned spans.

DOM nodes in the scroller dropped from 2,904 to 1,224. Median frame through a sustained scroll is 6 ms. p95 is 12 ms.

RSVP removes regressions and parafoveal processing. Comprehension falls off past roughly 500 wpm for anything longer than a sentence (Rayner et al., 2016). It works, but your brain has physical limits.

Try it yourself

The fastest way to read your next book.

Import EPUB, PDF, Kindle, Word, Markdown and the rest — thirty-nine formats in all. ReadKinetic runs entirely in your browser: nothing uploaded, nothing tracked.

Open ReadKinetic Free