True Focus
A frame of four corner brackets hops from word to word. Whichever word it lands on snaps into sharp focus while the rest of the line stays soft and blurred, like a camera hunting for its subject.
Look closer at every word
How to use it
- Paste the snippet just before
</head>. - Add
data-true-focusto a heading or line of text.
Options
Add any of these attributes to the same element.
data-duration | How long each word stays in focus. Default 1300 ms. |
data-blur | How blurred the other words are. Default 5 px. |
data-color | Colour of the frame. Default #a855f7. |
data-glow | Colour of the soft glow round the frame, or none. Default the frame colour. |
data-hover | Focus follows the pointer instead of hopping by itself. On touch screens, tap a word. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// True Focus: a frame of four corner brackets hops from word to word. The
// framed word snaps into focus while the rest of the line stays blurred.
// Usage: <h1 data-true-focus>Look closer at this</h1>
// Optional: data-blur (how blurred the other words are, px; default 5),
// data-duration (how long each word stays in focus, ms; default 1300),
// data-color (frame colour; default #a855f7), data-glow (colour of a soft
// glow round the frame; default the frame colour, "none" for no glow),
// data-hover (focus follows the pointer instead of hopping on a timer; on
// touch screens tap a word).
var DEFAULTS = { blur: 5, duration: 1300, color: '#a855f7' };
var css =
'.trf-word{display:inline-block;white-space:nowrap;transition:filter .45s ease,opacity .45s ease}' +
'.trf-frame{position:absolute;left:0;top:0;pointer-events:none;opacity:0}' +
'.trf-c{position:absolute;width:.22em;height:.22em;border:0 solid currentColor}' +
'.trf-tl{left:0;top:0;border-width:.07em 0 0 .07em;border-top-left-radius:.04em}' +
'.trf-tr{right:0;top:0;border-width:.07em .07em 0 0;border-top-right-radius:.04em}' +
'.trf-bl{left:0;bottom:0;border-width:0 0 .07em .07em;border-bottom-left-radius:.04em}' +
'.trf-br{right:0;bottom:0;border-width:0 .07em .07em 0;border-bottom-right-radius:.04em}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function split(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var words = [];
text.split(' ').forEach(function (w, i) {
if (i) el.appendChild(document.createTextNode(' '));
var s = document.createElement('span');
s.className = 'trf-word';
s.setAttribute('aria-hidden', 'true');
s.textContent = w;
el.appendChild(s);
words.push(s);
});
return words;
}
function setup(el) {
var d = el.dataset;
if (reduced()) return;
var blur = d.blur !== undefined ? +d.blur : DEFAULTS.blur;
var duration = +d.duration || DEFAULTS.duration;
var color = d.color || DEFAULTS.color;
var glow = d.glow || color;
var hover = d.hover !== undefined && d.hover !== 'false';
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var words = split(el);
var frame = document.createElement('span');
frame.className = 'trf-frame';
frame.setAttribute('aria-hidden', 'true');
frame.style.color = color;
if (glow !== 'none') frame.style.filter = 'drop-shadow(0 0 .12em ' + glow + ')';
['tl', 'tr', 'bl', 'br'].forEach(function (c) {
var s = document.createElement('span');
s.className = 'trf-c trf-' + c;
frame.appendChild(s);
});
el.appendChild(frame);
var current = -1;
var shown = false;
var move = null;
// Blur everything except word i, and glide the frame round it.
function focus(i, instant) {
current = i;
words.forEach(function (w, j) {
w.style.filter = j === i ? 'none' : 'blur(' + blur + 'px)';
w.style.opacity = j === i ? 1 : 0.55;
});
var w = words[i];
var fs = parseFloat(getComputedStyle(el).fontSize);
var pad = fs * 0.12;
var box = {
x: w.offsetLeft - pad,
y: w.offsetTop - pad * 0.4,
width: w.offsetWidth + pad * 2,
height: w.offsetHeight + pad * 0.8
};
if (move) move.pause();
if (instant || !shown) {
anime.utils.set(frame, box);
if (!shown) { shown = true; anime.animate(frame, { opacity: [0, 1], scale: [1.25, 1], duration: 450, ease: 'out(3)' }); }
} else {
move = anime.animate(frame, Object.assign({ duration: 520, ease: 'inOut(3)' }, box));
}
}
function next() { focus((current + 1) % words.length); }
var timer = null;
var visible = false;
function schedule() {
clearTimeout(timer);
if (visible && !hover) timer = setTimeout(function () { next(); schedule(); }, duration);
}
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && current < 0) focus(0);
schedule();
}, { threshold: 0.2 }).observe(el);
// Keep the frame on its word when the text reflows.
new ResizeObserver(function () { if (current >= 0) focus(current, true); }).observe(el);
if (hover) {
words.forEach(function (w, i) {
w.addEventListener('pointerenter', function () { if (i !== current) focus(i); });
w.addEventListener('pointerdown', function () { if (i !== current) focus(i); });
});
}
}
function init() {
document.querySelectorAll('[data-true-focus]').forEach(function (el) {
if (el._trf) return;
el._trf = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-true-focus>Look closer at every word</h1>
<!-- focus the word under the pointer: -->
<h1 data-true-focus data-hover data-blur="3">Pick a word</h1>