<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Esat Turan</title>
    <description>The latest articles on DEV Community by Esat Turan (@esatturan).</description>
    <link>https://dev.to/esatturan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4104408%2Fe54e1744-e933-45f0-b0f7-9193fa484c16.jpg</url>
      <title>DEV Community: Esat Turan</title>
      <link>https://dev.to/esatturan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/esatturan"/>
    <language>en</language>
    <item>
      <title>Arabic letters stop joining the moment you split the word</title>
      <dc:creator>Esat Turan</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:26:43 +0000</pubDate>
      <link>https://dev.to/esatturan/arabic-letters-stop-joining-the-moment-you-split-the-word-404c</link>
      <guid>https://dev.to/esatturan/arabic-letters-stop-joining-the-moment-you-split-the-word-404c</guid>
      <description>&lt;p&gt;If you have ever rendered Arabic text in a browser and found the letters sitting apart like beads on a string instead of joined into a word, there is a good chance nothing is wrong with your font, your encoding, or your &lt;code&gt;dir&lt;/code&gt; attribute. The chance is that something in your code split the string.&lt;/p&gt;

&lt;p&gt;I hit this building right-to-left support for &lt;a href="https://www.readkinetic.com/app" rel="noopener noreferrer"&gt;ReadKinetic&lt;/a&gt;, a speed reader that shows one word at a time. This post is what I measured, including the fix that is recommended everywhere and does not work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why splitting is the natural thing to do
&lt;/h2&gt;

&lt;p&gt;Speed readers of the RSVP kind position each word by its optimal recognition point — a letter slightly left of centre that your eye should land on. The word is placed so that letter sits on a fixed mark, and because the mark never moves, your eye never has to.&lt;/p&gt;

&lt;p&gt;The obvious implementation splits the word into three: everything before the pivot, the pivot, everything after. You right-align the first piece against the mark, put the pivot on it, left-align the last piece after it. You get exact positioning and a coloured pivot letter for free, because it is now its own element.&lt;/p&gt;

&lt;p&gt;In Latin this is invisible. &lt;code&gt;read&lt;/code&gt; cut into &lt;code&gt;re&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;d&lt;/code&gt; renders identically to &lt;code&gt;read&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  In Arabic it is ruinous
&lt;/h2&gt;

&lt;p&gt;Arabic letters are &lt;strong&gt;contextual&lt;/strong&gt;. Most have four forms — isolated, initial, medial, final — and which one the shaper draws depends on the letters beside it. The letter ه is &lt;code&gt;ه&lt;/code&gt; alone, &lt;code&gt;هـ&lt;/code&gt; at the start, &lt;code&gt;ـهـ&lt;/code&gt; in the middle, and &lt;code&gt;ـه&lt;/code&gt; at the end. Same character, same code point, four shapes.&lt;/p&gt;

&lt;p&gt;When you split a word into separate elements, each fragment is shaped on its own. The shaper has no idea the other pieces exist. A letter that should be medial is now the last letter of its fragment, so it is drawn in its final form, and the next fragment starts a new word as far as the shaper is concerned.&lt;/p&gt;

&lt;p&gt;The word does not just look slightly off. It comes apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, measured
&lt;/h2&gt;

&lt;p&gt;I took المكتبة (&lt;em&gt;al-maktaba&lt;/em&gt;, "the library"), rendered it whole, then rendered it as three fragments split at the pivot, and measured both.&lt;/p&gt;

&lt;p&gt;The fragments came out &lt;strong&gt;11.8% wider&lt;/strong&gt; than the same word drawn in one piece.&lt;/p&gt;

&lt;p&gt;That extra width is the letters disconnecting. Joined Arabic letters share their connecting strokes; isolated and final forms each carry their own terminal, plus the tail that a connected form does not have. Eleven per cent is the visual difference between a word and a pile of letters.&lt;/p&gt;

&lt;p&gt;It is also a good signal to test against, because it does not require you to read the script. If splitting a string changes its rendered width, the shaping changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this ships and stays shipped
&lt;/h2&gt;

&lt;p&gt;Because it is invisible to the person who wrote it.&lt;/p&gt;

&lt;p&gt;A developer who does not read Arabic looks at the output and sees Arabic-looking glyphs in the right order, right-to-left, in a reasonable font. Nothing about it says "broken". It takes a reader of the script about a quarter of a second, and they will usually screenshot it rather than file a bug, because explaining it is more work than it is worth.&lt;/p&gt;

&lt;p&gt;So it survives review, it survives QA, and it ships. This is the general shape of the problem: the bug is silent to everyone empowered to fix it, and obvious to everyone affected by it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that is recommended everywhere
&lt;/h2&gt;

&lt;p&gt;Search for this and you will be told to use &lt;strong&gt;U+200D ZERO WIDTH JOINER&lt;/strong&gt;. Append it to the fragment before the cut and prepend it to the fragment after, and each fragment believes it has a neighbour, so the shaper draws connected forms.&lt;/p&gt;

&lt;p&gt;For measuring, the same trick is supposed to let you ask "how wide is the first N letters of this word, as they will actually be drawn" by measuring the slice with a joiner stuck on the end.&lt;/p&gt;

&lt;p&gt;I implemented it. Then I measured whether it had done anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;48px "Geeza Pro", "Noto Naskh Arabic", serif&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ZWJ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x200D&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// U+200D, the recommended fix&lt;/span&gt;

&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;measureText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;المك&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// 44.1&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;measureText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;المك&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ZWJ&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// 44.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical. To the tenth of a pixel, with and without the joiner. Canvas &lt;code&gt;measureText&lt;/code&gt; was giving me the same number either way, which meant the joiner was not changing the shaping — it was being ignored.&lt;/p&gt;

&lt;p&gt;And 44.1 is wrong. Measured in the DOM, where the browser shapes the complete word and I ask where the pivot actually landed inside it, the answer is &lt;strong&gt;28.1 pixels&lt;/strong&gt;. The canvas was placing my fixation mark sixteen pixels — more than half a letter — away from where the letter was going to be drawn.&lt;/p&gt;

&lt;p&gt;Two failures stacked: the fragments were shaped wrong, and the measurement of them was wrong in the same direction, so nothing disagreed with anything and the whole thing looked like it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask the browser instead
&lt;/h2&gt;

&lt;p&gt;The only thing that knows where a letter will be drawn is the thing that draws it. So the word goes into a hidden element, complete and unmodified, and a &lt;code&gt;Range&lt;/code&gt; over the pivot character reports where it ended up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// whole, never sliced&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRange&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstChild&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstChild&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pivot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;whole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// distance from the edge the word STARTS at, which in&lt;/span&gt;
&lt;span class="c1"&gt;// a right-to-left word is the right-hand one&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rtl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shaper sees a complete word, so it shapes it correctly, and the range reports the position of the pivot &lt;em&gt;within that correct shaping&lt;/em&gt;. There is no fragment anywhere in this, which is the point.&lt;/p&gt;

&lt;p&gt;Ranges force layout, so this is not free the way canvas is, and it is cached per word. In a reader showing fifteen words a second, the same words come round constantly, and the cache hit rate does the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then move the word instead of cutting it
&lt;/h2&gt;

&lt;p&gt;With the offset known, the word never needs to be split at all. It is drawn whole in one element and translated so the pivot lands on the mark — the shift is the pivot's own centre, measured from the word's centre.&lt;/p&gt;

&lt;p&gt;The shaper only ever sees a complete word. The reader still gets a fixed fixation point. Measured in the running app, the pivot lands within &lt;strong&gt;0.01px&lt;/strong&gt; of the fixation mark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Colouring one letter without splitting the text
&lt;/h2&gt;

&lt;p&gt;The remaining problem is that highlighting the pivot letter is also a split. Wrapping it in a &lt;code&gt;span&lt;/code&gt; to colour it puts an element boundary inside the word.&lt;/p&gt;

&lt;p&gt;It is worth knowing exactly which boundaries hurt, because they are not all the same. Measured just now in Chrome 148, on المكتبة at 48px:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what sits inside the word&lt;/th&gt;
&lt;th&gt;rendered width&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nothing — the whole word&lt;/td&gt;
&lt;td&gt;136.28px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; around one letter&lt;/td&gt;
&lt;td&gt;136.28px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;span style="color:red"&amp;gt;&lt;/code&gt; around one letter&lt;/td&gt;
&lt;td&gt;136.28px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;three &lt;code&gt;display:inline-block&lt;/code&gt; pieces&lt;/td&gt;
&lt;td&gt;152.31px — &lt;strong&gt;11.8% wider&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a plain or coloured inline &lt;code&gt;span&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; break shaping in current Blink. What breaks it is taking the pieces out of the inline flow — &lt;code&gt;inline-block&lt;/code&gt;, a float, a block, or three separate elements positioned by hand, which is exactly what the naive RSVP implementation does.&lt;/p&gt;

&lt;p&gt;I still did not want to rest on the span. Browsers have genuinely differed here — W3C &lt;a href="https://www.w3.org/International/track/issues/358" rel="noopener noreferrer"&gt;ISSUE-358&lt;/a&gt; is the working group arguing about whether cursive scripts should connect across inline boundaries at all, with the note that "browsers differ in their behavior" and Firefox possibly only joining when the font matches. A highlight that silently breaks a word on one engine is precisely the bug this whole exercise is about.&lt;/p&gt;

&lt;p&gt;So the word is drawn twice. The lower copy is the word in its normal colour. The upper copy is the same word in the highlight colour, absolutely positioned in the same place, and clipped with &lt;code&gt;clip-path: inset(...)&lt;/code&gt; to the pivot's own column — the left inset being the pivot's offset, the right inset being everything past it.&lt;/p&gt;

&lt;p&gt;Both copies are complete words, shaped independently and identically, so they land on exactly the same pixels. The top one shows through only where the pivot is. One letter is coloured and nothing was ever cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;p&gt;If you are rendering Arabic, Persian or Urdu — the three joining scripts I handle — the rule is that a word is atomic. Anything that slices it changes what is drawn. That includes measuring it in slices, which is the version that will get you, because it fails silently and looks like arithmetic.&lt;/p&gt;

&lt;p&gt;And do not trust the zero-width joiner without measuring whether it did anything. On canvas, in my testing, it did not.&lt;/p&gt;

&lt;p&gt;Hebrew, for what it is worth, is the easy case. It runs right-to-left but its letters do not join, so the ordinary split-at-the-pivot machinery is correct for it with the geometry mirrored.&lt;/p&gt;

&lt;p&gt;One honest caveat, which I also put on the &lt;a href="https://www.readkinetic.com/blog/speed-reading-arabic-hebrew-persian-urdu" rel="noopener noreferrer"&gt;Arabic and Hebrew feature page&lt;/a&gt;: all of the above is verified by measurement — glyph widths, pivot offsets, page mapping — and not by a person who reads the script. Those are not the same thing. If you read Arabic, Persian, Urdu or Hebrew and something looks wrong, &lt;a href="https://www.readkinetic.com/contact" rel="noopener noreferrer"&gt;I would genuinely like to hear about it&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Related: &lt;a href="https://www.readkinetic.com/blog/speed-reading-arabic-hebrew-persian-urdu" rel="noopener noreferrer"&gt;Arabic and Hebrew speed reader&lt;/a&gt; · &lt;a href="https://www.readkinetic.com/blog/speed-reading-japanese-chinese-thai" rel="noopener noreferrer"&gt;Speed reading Japanese, Chinese and Thai&lt;/a&gt; · &lt;a href="https://www.readkinetic.com/features/anticipatory-pacing" rel="noopener noreferrer"&gt;Anticipatory pacing&lt;/a&gt; · &lt;a href="https://www.readkinetic.com/blog/how-a-pdf-page-knows-its-words" rel="noopener noreferrer"&gt;How a PDF page knows its words&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>i18n</category>
      <category>css</category>
    </item>
    <item>
      <title>Safari dropped my scroll because of a CSS fade on an ancestor</title>
      <dc:creator>Esat Turan</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:09:34 +0000</pubDate>
      <link>https://dev.to/esatturan/safari-dropped-my-scroll-because-of-a-css-fade-on-an-ancestor-51a5</link>
      <guid>https://dev.to/esatturan/safari-dropped-my-scroll-because-of-a-css-fade-on-an-ancestor-51a5</guid>
      <description>&lt;p&gt;On my iPhone the rendered pages in &lt;a href="https://www.readkinetic.com/app" rel="noopener noreferrer"&gt;ReadKinetic&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes that were right and did not work
&lt;/h2&gt;

&lt;p&gt;A non-passive &lt;code&gt;touchmove&lt;/code&gt; 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 &lt;code&gt;preventDefault&lt;/code&gt;. Every frame waits on JavaScript. Chrome absorbs that. Safari just gets annoyed and drops the scroll. I fixed it. Still broken.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The fade was the bug
&lt;/h2&gt;

&lt;p&gt;The reading surface had &lt;code&gt;active:brightness-95&lt;/code&gt; and &lt;code&gt;transition-all&lt;/code&gt;. A CSS filter animating on touch, on an ancestor of the scroller.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;:active&lt;/code&gt;. That is why the working spot could never be found twice.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;There is a live, draggable reproduction of this in the &lt;a href="https://www.readkinetic.com/blog/how-a-pdf-page-knows-its-words" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/strong&gt;&lt;br&gt;
Open it on an iPhone, drag the box with the filter on and then off, and compare the counts.&lt;br&gt;
A desktop browser shows no difference — that is expected, this is a mobile compositing behaviour.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A bare version of that box, with no framework around it, is at &lt;a href="https://www.readkinetic.com/repro/safari-filter-scroll.html" rel="noopener noreferrer"&gt;readkinetic.com/repro/safari-filter-scroll.html&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Filed as &lt;a href="https://bugs.webkit.org/show_bug.cgi?id=322849" rel="noopener noreferrer"&gt;WebKit bug 322849&lt;/a&gt;, under Compositing. Simon Fraser from the WebKit team answered the same day:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I believe this happens when we remove and re-add a UIScrollView to the view hierarchy as ancestor layers change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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 &lt;code&gt;UIScrollView&lt;/code&gt;. Change the compositing layers above it and that view gets detached and put back, and a &lt;code&gt;UIScrollView&lt;/code&gt; that disappears in the middle of a gesture takes the touch it was tracking with it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The bug is open and unassigned. No fix, no timeline. But I know what I was looking at now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder problem underneath
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting the words does not work
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;There is an interactive version of this in the original post&lt;/strong&gt; — you can switch between counting and&lt;br&gt;
matching and watch the pages land or drift: &lt;a href="https://www.readkinetic.com/blog/how-a-pdf-page-knows-its-words" rel="noopener noreferrer"&gt;https://www.readkinetic.com/blog/how-a-pdf-page-knows-its-words&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So the pages are found, not counted
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then three things broke.&lt;/p&gt;

&lt;h3&gt;
  
  
  A page can open on a one-word paragraph
&lt;/h3&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stranded after dialogue
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a chapter matches the table of contents?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One box per word, not one per line
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pdf.js&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Letterspaced titles
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final cost
&lt;/h2&gt;

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

&lt;p&gt;A 1,390-page PDF opens and scrolls. Canvases mounted at any moment: 4 to 6, not 1,390. Pages are memoised. &lt;code&gt;currentIndex&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>debugging</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
