Cylinder Roll-in
Lines turn into view one after another as if printed on a rotating drum, rolling up from underneath to face you. The lines are worked out from where the browser really wraps the text, and again whenever the width changes.
Every line rolls up from underneath, as if it were printed on a drum turning slowly towards you.
How to use it
- Paste the snippet just before
</head>. - Add
data-cylinder-roll-into a heading or paragraph.
Options
Add any of these attributes to the same element.
data-loop | Roll in, hold, roll on over the top and in again. |
data-hold | How long the text stays before rolling away, when looping. Default 2200 ms. |
data-duration | How long each line takes to roll in. Default 1100 ms. |
data-stagger | Gap between lines. Default 140 ms. |
data-delay | Wait before the first line. Default 0 ms. |
Works on plain text: links or bold inside the element are flattened to text when it’s split into lines.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Cylinder Roll-in: lines turn into view one after another as if printed
// on a rotating drum, rolling up from underneath to face you. Lines are
// worked out from where the browser actually wraps the text, and again
// whenever the width changes.
// Usage: <h1 data-cylinder-roll-in>Roll it in</h1>
// Optional: data-loop (lines roll on over the top and in again),
// data-hold (ms to stay before rolling away), data-duration (ms per line),
// data-stagger (ms between lines), data-delay (ms before starting).
var DEFAULTS = { duration: 1100, stagger: 140, delay: 0, hold: 2200 };
var css =
// Each line gets its own perspective, so every line is seen head-on.
'.cyr-line{display:block;perspective:12em}' +
// Turning round a point half a line behind the text makes it travel
// round a drum rather than spin flat on the spot.
'.cyr-inner{display:inline-block;white-space:nowrap;transform-origin:50% 50% -0.6em;' +
'backface-visibility:hidden;-webkit-backface-visibility:hidden;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Lay the words out, see where the browser breaks them, then rebuild the
// text as one block per line.
function split(el, words) {
el.textContent = '';
var spans = words.map(function (w, i) {
if (i) el.appendChild(document.createTextNode(' '));
var s = document.createElement('span');
s.textContent = w;
el.appendChild(s);
return s;
});
var rows = [];
var top = null;
spans.forEach(function (s, i) {
if (top === null || s.offsetTop > top + 2) { rows.push([]); top = s.offsetTop; }
rows[rows.length - 1].push(words[i]);
});
el.textContent = '';
return rows.map(function (row) {
var line = document.createElement('span');
line.className = 'cyr-line';
line.setAttribute('aria-hidden', 'true');
var inner = document.createElement('span');
inner.className = 'cyr-inner';
inner.textContent = row.join(' ');
line.appendChild(inner);
el.appendChild(line);
return inner;
});
}
function play(el) {
var st = el._cyr;
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var tl = anime.createTimeline({ loop: loop, loopDelay: 300, delay: st.played ? 0 : +d.delay || DEFAULTS.delay });
tl.add(st.lines, {
// From the underside of the drum round to the front, settling with a
// small rock past centre.
rotateX: { from: -100, to: 0, ease: 'outBack(1.3)' },
opacity: { from: 0, to: 1, duration: duration * 0.4, ease: 'linear' },
duration: duration,
delay: anime.stagger(stagger)
});
if (loop) {
tl.add(st.lines, {
rotateX: 100,
opacity: 0,
duration: duration * 0.6,
ease: 'in(3)',
delay: anime.stagger(stagger * 0.6)
}, '+=' + (+d.hold || DEFAULTS.hold));
}
st.tl = tl;
st.played = true;
}
function hide(lines) {
anime.utils.set(lines, { rotateX: -100, opacity: 0 });
}
function init() {
if (reduced()) return;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.2 });
var els = [];
document.querySelectorAll('[data-cylinder-roll-in]').forEach(function (el) {
if (el._cyr) return;
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
var st = (el._cyr = { words: text.split(' ') });
st.lines = split(el, st.words);
st.width = el.clientWidth;
hide(st.lines);
els.push(el);
io.observe(el);
});
if (!els.length) return;
function resplit(el) {
var st = el._cyr;
if (st.tl) st.tl.pause();
st.lines = split(el, st.words);
st.width = el.clientWidth;
if (!st.played) hide(st.lines);
else if (el.dataset.loop !== undefined && el.dataset.loop !== 'false') play(el);
}
// Web fonts can change where lines break, so split again once they load...
if (document.fonts) document.fonts.ready.then(function () { els.forEach(resplit); });
// ...and whenever the width changes.
var timer;
window.addEventListener('resize', function () {
clearTimeout(timer);
timer = setTimeout(function () {
els.forEach(function (el) { if (el.clientWidth !== el._cyr.width) resplit(el); });
}, 200);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p data-cylinder-roll-in>
Every line rolls up from underneath,
as if it were printed on a turning drum.
</p>