Type the Word

Jan 15, 2024

Type the Word is a tool for typing through the Bible. It overlays what you have typed onto a passage of scripture, so that you can focus on typing accurately and the passage itself. It tracks what you have typed during the current session and in previous sessions allowing you to monitor your progress through larger passages of scripture.

I created Type the Word for myself and others who want to meditate on the Word of God while practicing their typing skill. I have found that typing through the Bible has been a great way to meditate on it.

Getting to start something fresh and build a tool I would be using was a great way to return to the basics. I scaffolded Type the Word as a Nextjs app with Create T3 App, and used the opportunity to try some new tech (TRPC, Drizzle, Vercel/OG, and PlanetScale). In the following sections, I’ll talk through the implementation details for Type the Word’s core features.

PassageSelector

The PassageSelector is two searchable combo boxes for selecting a passage by book and chapter. I considered making this a composite widget so that you could navigate between inputs via the arrow keys and submit from either via Enter . But there isn’t a general composite aria-role, so I decided to keep the inputs separate and add custom focus behavior.

Behavior 1: When a book is selected the chapter combo box is auto-focused.

Behavior 2: When only selecting a book the passage selection auto-submits after a delay.

And then finally the passage selector syncs with the URL.

Here is a Github link to the PassageSelector.

Verse rendering

Each Passage is rendered with the assumption that you can only have one verse being typed at a time. This verse is rendered in the CurrentVerse component, which tracks a list of keystrokes you have typed and uses a reduce to create a typed version of the verse. This typed version is compared to the correct version letter by letter to give you live feedback on the accuracy of your typing.

This list of keystrokes is held in a Jotai atom that is only read from the CurrentVerse component so that only the CurrentVerse rerenders onInput. This ensures that typing through verses is performant on Psalm 119 — the longest chapter in the Bible.

I used onInput, because onKeyDown.key wasn’t working on Android mobile Chrome. According to the current canIUse the latest version does support this field, but there were no previously reported versions that supported it. Related issues one and two.

Keyboard Accessibility

Verses are rendered as inline elements. Adding interactivity to inline elements is tricky because there are uninteractive gaps between the text. Here is an example: try hovering these verses and you’ll only see the pointer on the gray regions of text.

1 Irure do nisi adipisicing esse et cupidatat aute. 2 Mollit eu dolor mollit voluptate. 3 adipisicing esse et cupidatat aute.
Resizable demo of the uninteractive space around inline content.

Something else I cared about was making it obvious what clicking on different verses would do. A verse's text doesn't necessarily indicate that “clicking here switches to this verse.” I could have added aria-labels, but I also wanted to make it obvious for mouse users.

To fix both problems I overlayed buttons on top of each verse.

Positioning these buttons was tricky so in the next few demos I’ll walk through the different approaches I tried.

Initially, I tried adding relative to the span wrapping each verse, and made each button absolute inset. Unfortunately, when inline elements wrap so that their horizontal end is to the left of their horizontal start then the inset absolute elements has a width of 0.

1 Irure do nisi adipisicing esse et cupidatat aute. 2 Mollit eu dolor mollit voluptate. 3 adipisicing esse et cupidatat aute. 4 Mollit eu mollit voluptate. 5 do nisi adipisicing esse et cupidatat aute. 6 eu dolor mollit voluptate. 7 Irure do nadipisicing esse et cupidatat aute. 8 Mollit eu mollit voluptate. 9 Dolor deserunt adipisicing excepteur pariatur aliqua amet. 10 Elit Lorem magna qui voluptate occaecat cillum commodo aliqua fugiat amet do eu adipisicing id esse.11 Do nisi adipisicing esse et cupidatat aute. 12 Mollit eu dolor mollit voluptate. 13 Voluptate occaecat cillum commodo aliqua fugiat amet do eu adipisicing id esse. 14 Mollit eu dolor mollit voluptate. 15 Irure do sse et cupidatat aute. 16 Mollit eu mollit voluptate.
Resizable demo of absolute inset.

Try resizing ^ to see what absolute positioning looks like in an inline element.

Next, I tried using the width of the parent on each button in combination with absolute top-0 left-0. This way our button never collapses to a width of zero.

1 Irure do nisi adipisicing esse et cupidatat aute. 2 Mollit eu dolor mollit voluptate. 3 adipisicing esse et cupidatat aute. 4 Mollit eu mollit voluptate. 5 do nisi adipisicing esse et cupidatat aute. 6 eu dolor mollit voluptate. 7 Irure do nadipisicing esse et cupidatat aute. 8 Mollit eu mollit voluptate. 9 Dolor deserunt adipisicing excepteur pariatur aliqua amet. 10 Elit Lorem magna qui voluptate occaecat cillum commodo aliqua fugiat amet do eu adipisicing id esse.11 Do nisi adipisicing esse et cupidatat aute. 12 Mollit eu dolor mollit voluptate. 13 Voluptate occaecat cillum commodo aliqua fugiat amet do eu adipisicing id esse. 14 Mollit eu dolor mollit voluptate. 15 Irure do sse et cupidatat aute. 16 Mollit eu mollit voluptate.
Resizable demo of absolute top-0 left-0 with width based on the bounding box of the Passage.

I found that even if you have the width of the parent, there is no way to center the button without tracking each verse's position relative to the parent. I needed to position the button with width relative to the Passage and the top relative to the verse.

So I

  • moved the relative class from the verse to the Passage level
  • made the buttons absolute relative to the entire Passage
  • and calculated the top position by subtracting the Passage’s top from the verse’s top.

Here is a simplified version:

import { useRef } from 'react'
import { StartVerses } from './startVerses'
import { EndVerses } from './endVerses'
import { useRect } from './useRect'

export default function App() {
    const passageRef = useRef<HTMLDivElement>(null)
    const verseRef = useRef<HTMLDivElement>(null)
    const passageRect = useRect(passageRef)
    const verseRect = useRect(verseRef)
    return (
        <div className="p-5">
            <div
                className="relative overflow-auto resize border-2 p-3"
                ref={passageRef}
            >
                <div>
                    <StartVerses />
                    
                    {/* To get "resize" working I am using ResizeObserver in 
                        "useRect" instead of just listening to the resize event. 
                        But inline elements don't trigger ResizeOberserver
                        except when they are added/removed to the DOM.
                        This hack ("key={Math.random()"} is just for this
                        example to enable the resizable corner button. */}
                    <span key={Math.random()} ref={verseRef}>
                        4 Mollit eu mollit voluptate.
                    </span>
                    
                    <EndVerses />
                </div>
                {verseRect && passageRect && (
                    <button
                        className="absolute text-black border-2 border-black backdrop-blur-sm bg-white/80 flex justify-center items-center"
                        style={{
                            top: verseRect.top - passageRect.top - 8,
                            left: 4,
                            width: passageRect.width - 12,
                            height: verseRect.height + 16,
                        }}
                    >
                        Click here
                    </button>
                )}
            </div>
        </div>
    )
}

Auto Scroll

Once you have selected a verse it auto-scrolls to position the verse close to the top of your screen, this allows you to continuously type through long passages.

Most verses are rendered with ReadonlyVerse. When you select a new verse it is rerendered this verse as the CurrentVerse. CurrentVerse is similar to ReadonlyVerse except it contains a hidden input to contain state, and an “anchor” element which I’ll explain shortly.

For reference: here is a simplified diff from ReadonlyVerse to CurrentVerse, keep scrolling as I'll explain this line by line below.

export function CurrentVerse({setCurrentVerse, resetState}: {setCurrentVerse: () => void, resetState: () => void}) {
    const inputRef = useRef<HTMLInputElement>(null)
    const [passageId] = useAtom(passageIdAtom)

    return (
        <span
            onClick={() => {
-                setCurrentVerse()
-                resetState()
            }}
            ref={ref}
        >
+            <span
+                id={`${passageId}-scroll-anchor`}
+                className={
+                    'inline-block -translate-y-[300px] lg:-translate-y-[330px]'
+                }
+            />

            {verse.nodes.map((atom, aIndexPrime) => {
               /* rendering logic */
            })}

            {rect && passageRect && !isArenaFocused ? (
-                <button onClick={() => inputRef.focus()}>
-                    <span>Switch to {verse.verse.value}</span>
+                <button onClick={() => inputRef.focus()}>
+                    <span>Continue typing verse {verse.verse.value}</span>
                </button>
            ) : null}

+            <input
+                type="text"
+                className="peer fixed h-0 max-h-0 opacity-0"
+                onInput={handleInput}
+                tabIndex={-1}
+                onFocus={() => {
+                    document
+                        .getElementById(`${passageId}-scroll-anchor`)
+                        ?.scrollIntoView({
+                            block: 'start',
+                            behavior: 'smooth',
+                        })
+                }}
+                ref={inputRef}
+                autoFocus={true}
+            />
        </span>
    )
}
  1. Clicking on a ReadonlyVerse sets that verse to be current in the global state.

    -  setCurrentVerse()
    -  resetState()
    
  2. That verse is rendered as the CurrentVerse which contains an <input autofocus={true} />

  3. Autofocus triggers the onFocus which then gets the position of the anchor element ${passageId}-scroll-anchor and scrolls to it with scrollIntoView.

    +   onFocus={() => {
    +       document
    +           .getElementById(`${passageId}-scroll-anchor`)
    +           ?.scrollIntoView({
    +               block: 'start',
    +               behavior: 'smooth',
    +           })
    +   }}
    
  4. scrollIntoView doesn’t support adding an offset. But we can add one, by scrolling to the anchor element, which is absolutely positioned above the CurrentVerse.

    +   <span
    +       id={`${passageId}-scroll-anchor`}
    +       className={
    +           'inline-block -translate-y-[300px] lg:-translate-y-[330px]'
    +       }
    +   />
    

    The anchor is positioned exactly the distance of the navigation + PassageSelector, so navigating to and from a passage doesn’t trigger a scroll.

  5. This anchor is only rendered in the CurrentVerse and is scoped with useId at the Passage level so that you can have multiple passages on the same page.

Here is a Github link to CurrentVerse.

Cursor animation

The CurrentVerse renders your letters comparing the typed version to the correct version and adding classes to each letter to indicate correctness.

For example, if a user types:

The markup will look like this:

The Cursor is an animated element that uses these classes to position itself after the last typed letter.

  1. First thing to notice is that I am using requestAnimationFrame to potentially animate the cursor every frame.

    export function useCursor(passageId: string): void {
        /* ... */
        useEffect(() => {
            function move() {
               /* ... */
            }
    
            let frame = requestAnimationFrame(function loop() {
                frame = requestAnimationFrame(loop)
                move()
            })
    
            return () => {
                cancelAnimationFrame(frame)
            }
        }, [passageId])
    }
    

    This means there is a bit of CPU work on every frame and the animation can't just be handed over to the GPU (hardware-acceleration). This is kinda a bummer, because I am animating transforms only and using the WAAPI via motion one, so the other parts of my animation are acceleratable.

    But at the same time I think this sort of interactive dynamic animation will be the last type of animation that can be hardware-accelerated.

    I learned alot about hardware accelerated animation in my previous write up on sidebar animation performance. Check it out if you want to learn more.

  2. Within move I find the position of the next letter to type and get it's bounding box:

    let activeElement = document.querySelector(
        `#${CSS.escape(passageId)} .active-verse .active-word:not(.error) .letter:not(.correct):not(.incorrect):not(.extra)`,
    )
    let activeRect = activeElement?.getBoundingClientRect()
    

    Then I get the same info on the related Passage.

    const passage = document.querySelector(`#${CSS.escape(passageId)}`)
    const passageRect = passage?.getBoundingClientRect()
    

    And subtract the left and top of the letter from the left and top of the Passage. This gives me left and top of my cursor relative the Passage itself.

    const nextTop = activeRect.top - passageRect.top
    const nextLeft = activeRect.left - passageRect.left
    

    I then animate the cursor like so:

    animate(
        `#${CSS.escape(passageId)}-cursor`,
        {
            top: 0,
            left: 0,
            x: `${nextLeft}px`,
            y: `${nextTop}px`,
            width: `2px`,
            height: '22px',
        },
        {
            easing: [0.25, 0.46, 0.45, 0.94],
            duration: distance > 30 ? 0 : 0.085,
        },
    )
    

That's the gist of how the cursor is animated but there are two optimizations:

  1. At this rate, animate is called up to 60 times a second, and a lot of animations will be cancelled.

    We should really only call animate when there is a change in position. So I track previous position, and add an early return to move for when the position hasn't changed.

    const prev = useRef<{top: number; left: number}>({ top: 0, left: 0 })
    
    useEffect(() => {
        function move() {
                /* get DomRects */
    
                const nextTop = activeRect.top - passageRect.top
                const nextLeft = activeRect.left - passageRect.left
    
                if (
                    nextTop === prev.current.top &&
                    nextLeft === prev.current.left
                )
                    return
    
                prev.current = {
                    top: nextTop,
                    left: nextLeft,
                }
    
                /* animate */
        }
    }, [passageId])
    
  2. When the cursor animated over long distances it felt unpolished to me. So the other optimization, is not animating when the difference in position is over 30px. I used pythagorean theorem to find the distance between two points. And when the distance was over 30px I set the duration of my animation to 0.

    useEffect(() => {
        function move() {
                /* get DomRects */
    
                /* diff with previous */
    
                const distance = pythagorean(
                    prev.current.left ?? 0,
                    nextLeft,
                    prev.current.top ?? 0,
                    nextTop,
                )
    
                animate(
                    /* ... */,
                    {
                        easing: [0.25, 0.46, 0.45, 0.94],
                        duration: distance > 30 ? 0 : 0.085,
                    },
                )
        }
    }, [passageId])
    

    It's the little things. Here is a before and after.


    Before:

    After:

Here is a Github link to useCursor.

Hope you enjoyed this breakdown and give Type the Word a try. If you know someone that studies the Bible, it would mean a lot if you shared it with them.

Until next time 👋

Links

Subscribe to the newsletter

A monthly no filler update.

Contact me at