TrueFocus
Word-by-word phrase focus with a moving frame.
Live demo
01Each 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.
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.
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.
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.
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.
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.
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.
Implementation
02<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
03What 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
setIntervalthat bails out early under hover or pin β no rAF burning while paused. ResizeObserveronly 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 iswordEls.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, andSvelteMapfromsvelte/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 pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
text | string | required | Phrase to highlight word-by-word. |
cycleDuration | number | 1500 | Hold time per word, ms. |
order | "sequential" | "random" | "sequential" | Cycle order. |
autoStart | boolean | true | Start cycling on mount. |
pauseOnHover | boolean | true | Pause the cycle while pointer is over. |
color | string | "#4338ca" | Frame border and glow colour. |
glow | boolean | false | Render a soft glow around the frame. |
paddingX | number | 4 | Horizontal padding around each word, px. |
paddingY | number | 2 | Vertical padding around each word, px. |