TrueFocus

Word-by-word phrase focus with a moving frame.

Live demo

01

Each section mounts TrueFocus with a different combination of cycleDuration, pauseOnHover, padding, and glow. Click any word to pin focus β€” click again (or click another) to release.

Default Β· cycleDuration 1500 Β· pauseOnHover true

The default β€” slow read, frame slides every 1.5s. Hover pauses the cycle so users can read at their own pace.

Truefocusonthepresent

Fast cycle Β· cycleDuration 800 Β· sharp pace

Halving the cycle duration creates an urgent, kinetic read. Useful for kinetic-typography hero blocks where you want the frame to dance.

Movefastandshipoften

Slow cycle Β· cycleDuration 2400 Β· meditative pace

A longer hold per word feels deliberate β€” useful for poetry, manifestos, or anything you want the reader to actually read.

Less,butbetter

pauseOnHover false Β· cycle never stops

When set to false, the cycle ignores the cursor entirely β€” useful for purely decorative motion that shouldn't react to hovering UI elements above it.

Alwayscycling,neverresting

Pin via autoStart=false Β· click any word to lock

With autoStart=false the cycle never begins β€” the first word stays pinned until you click another. Perfect for "select a value" interactions.

PinawordwithclickorEnter

Generous padding Β· paddingX 18 Β· paddingY 10 Β· soft frame

Bigger padding plus a glow renders the frame as a soft halo rather than a tight outline β€” closest analogue the component offers to a "blur" effect.

Softhaloaroundtheactiveword

Random order Β· cycleDuration 1200 Β· poetic feel

order="random" jumps the focus to non-adjacent words β€” adds a stochastic feel suited to dark or maximalist designs.

Themindmovesinmanydirections

Long sentence Β· multi-line wrap Β· paddingX 6 paddingY 3

A long sentence demonstrates the ResizeObserver-driven re-measure: the frame tracks words across line breaks. Tight padding keeps the indicator snug at small line heights.

TworoadsdivergedinayellowwoodandsorryIcouldnottravelboth

Implementation

02
TrueFocus.svelte
<script lang="ts">
  import TrueFocus from '$lib/components/TrueFocus.svelte';
</script>
​
<TrueFocus text="Build something extraordinary" cycleDuration={1800} pauseOnHover />

TrueFocus measures every word's bounding rect once, then animates a single absolutely-positioned frame between them. ResizeObserver re-measures on layout change. Click or Enter pins a word; hover pauses the cycle. prefers-reduced-motion disables the morph transition entirely.

Logic explainer

03

What Does It Do? (Plain English)

TrueFocus draws a single coloured focus box around one word of a phrase, then slides and resizes that box from word to word on a configurable cadence. There is exactly one indicator element β€” when it moves it morphs (transforms its position, animates its width and height) rather than fading out one box and fading in another. Hover pauses the cycle. Click on a word to pin focus there.

Think of it as a teleprompter highlight: the camera operator's spotlight glides from the next word to the next, leaving the rest of the line untouched.

How It Works (Pseudo-Code)

state:
  activeIndex     = 0
  pinnedIndex     = null      // overrides activeIndex when set
  isHovering      = false
  measuredRect    = { left, top, width, height }
  prefersReduced  = matchMedia query

derive:
  words         = text.split(/\s+/) filter non-empty
  displayIndex  = pinnedIndex ?? activeIndex

events:
  on cycle interval:
    if pinned: skip
    if pauseOnHover and isHovering: skip
    activeIndex = cycleNext(activeIndex, words.length, order)

  on word click(idx):
    pinnedIndex = pinnedIndex === idx ? null : idx   // toggle pin

  on word keydown Enter or Space(idx):
    preventDefault
    treat as click

  on pointerenter / pointerleave:
    isHovering = true / false

re-measure on every displayIndex change:
  el      = wordEls[displayIndex]
  wRect   = wrapper.getBoundingClientRect()
  eRect   = el.getBoundingClientRect()
  rect    = {
    left:   eRect.left - wRect.left,
    top:    eRect.top  - wRect.top,
    width:  eRect.width,
    height: eRect.height
  }
  measuredRect = padRect(rect, paddingX, paddingY)

ResizeObserver(wrapper) β†’ re-measure on reflow / font-load / window-resize

reduced-motion:
  cycle never starts; transitions disabled on the focus box.

The Core Concept: One Morphing Indicator (Not Many Borders)

The naive implementation gives every word its own border and toggles .active. That works visually but you get fade-out/fade-in flicker and four rerendered borders during a transition. TrueFocus instead has one absolutely positioned indicator and animates its transform, width, and height in sync.

  Phrase wrapper (position: relative)
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  [Build]   something   extraordinary             β”‚   ← words (z-index: 1)
  β”‚   β—―β—―β—―β—―β—―                                          β”‚
  β”‚   β–²                                              β”‚
  β”‚   β”‚                                              β”‚
  β”‚   indicator absolutely positioned at the         β”‚
  β”‚   measured rect of word #0 (z-index: 0,          β”‚
  β”‚   pointer-events: none)                          β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

           cycle tick β†’ activeIndex = 1
           β–Ό

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Build   [something]   extraordinary             β”‚
  β”‚           ─────────                              β”‚
  β”‚           β–²                                      β”‚
  β”‚           β”‚                                      β”‚
  β”‚           same indicator element, different      β”‚
  β”‚           inline style: translate3d + width + height
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The indicator's CSS is transition: transform 360ms cubic-bezier(0.6, 0, 0.2, 1), width 360ms ..., height 360ms .... When measuredRect changes, the browser interpolates all four properties on the same easing curve β€” the box slides AND resizes in one fluid motion. No DOM thrash, no fade/fade flicker.

buildIndicatorStyle(rect, color, glow) is exported as a pure helper so unit tests can confirm the inline-style string for a given rect without rendering anything.

CSS Animation Strategy

The morph is a single CSS transition list β€” three position/size properties on one cubic-bezier so they share a velocity profile, plus shorter easing on border-color, box-shadow, and opacity:

.focus-box {
  transition:
    transform   360ms cubic-bezier(0.6, 0, 0.2, 1),
    width       360ms cubic-bezier(0.6, 0, 0.2, 1),
    height      360ms cubic-bezier(0.6, 0, 0.2, 1),
    border-color 240ms ease,
    box-shadow   240ms ease,
    opacity      240ms ease;
  will-change: transform, width, height;
}

translate3d on the position keeps the element on the GPU compositor; width/height do force a paint, but only inside the indicator's own bounding box (no neighbours reflow). Reduced-motion users get transition: none β€” the indicator pins to the first word and stays put while the rest of the text reads as static copy.

Performance

  • One indicator element, transform-driven; the rest of the phrase is plain text spans.
  • The cycle is a setInterval that bails out early under hover or pin β€” no rAF burning while paused.
  • ResizeObserver only fires when the wrapper actually changes size (window resize, font load, viewport reflow); quiet pages do nothing after mount.
  • A SvelteMap<number, HTMLElement> registers each word element via the bind-getter/setter pattern, so per-frame measurement is wordEls.get(displayIndex) β€” O(1) lookup.

State Flow Diagram

                       autoStart
  [mounted] ──────────────────────────────▢ [cycling]
                                                 β”‚
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
                β”‚ pointerenter  (pauseOnHover)   β”‚
                β–Ό                                β”‚
           [paused]                              β”‚
                β”‚                                β”‚
                β”‚ pointerleave                   β”‚
                β–Ό                                β”‚
            [cycling]                            β”‚
                β”‚                                β”‚
                β”‚ word click                     β”‚
                β–Ό                                β”‚
            [pinned]                             β”‚
                β”‚                                β”‚
                β”‚ click pinned word again        β”‚
                β–Ό                                β”‚
            [cycling] β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  prefers-reduced-motion: reduce
        β”‚
        β–Ό
   [static]  cycle never starts; indicator pinned to first word.

Props Reference

Prop Type Default Description
text string required The phrase, split on whitespace into words.
cycleDuration number 1500 Milliseconds each word stays in focus.
color string '#4338ca' Border and glow colour of the focus box.
glow boolean true Render the soft box-shadow glow around the indicator.
order 'sequential' | 'random' 'sequential' Cycle order through the words.
pauseOnHover boolean true Stop the cycle while the pointer is over the wrapper.
autoStart boolean true Begin cycling on mount.
paddingX number 8 Horizontal padding inside the focus box (px).
paddingY number 4 Vertical padding inside the focus box (px).
class string '' Extra classes on the wrapper span.

Edge Cases

Situation Behaviour
Empty text splitWords returns []; the indicator block is skipped ({#if words.length > 0}).
Single word cycleNext returns 0 for any input when total === 1; indicator parks on the only word.
'random' order with two words cycleNext always avoids repeating the current index, preventing visual stutter.
Window resize while open ResizeObserver fires; measureActiveWord recomputes the rect against the new wrapper position.
User has prefers-reduced-motion: reduce Cycle never starts; CSS transition disabled; indicator sits on word 0.
Click on currently-pinned word pinnedIndex toggles back to null; the cycle resumes from activeIndex.
Phrase prop changes mid-cycle The reactive words derivation recomputes; the $effect watching displayIndex and words.length fires a fresh measurement.
Word wrapping to a new line Indicator measures off the actual rect, so a word that wraps to line 2 gets the indicator on line 2 β€” the morph still works, it just travels diagonally.

Dependencies

  • Svelte 5.x β€” $state, $derived, $effect, untrack, and SvelteMap from svelte/reactivity.
  • ResizeObserver (native browser API) β€” no shim; the component bails out gracefully if the API is missing.
  • Zero external dependencies otherwise.

File Structure

src/lib/components/TrueFocus.svelte   # implementation
src/lib/components/TrueFocus.md       # this file (rendered inside ComponentPageShell)
src/lib/components/TrueFocus.test.ts  # vitest unit tests for the pure helpers
src/routes/truefocus/+page.svelte     # demo page

API

04
PropTypeDefaultDescription
textstringrequiredPhrase to highlight word-by-word.
cycleDurationnumber1500Hold time per word, ms.
order"sequential" | "random""sequential"Cycle order.
autoStartbooleantrueStart cycling on mount.
pauseOnHoverbooleantruePause the cycle while pointer is over.
colorstring"#4338ca"Frame border and glow colour.
glowbooleanfalseRender a soft glow around the frame.
paddingXnumber4Horizontal padding around each word, px.
paddingYnumber2Vertical padding around each word, px.