Logo Split Reveal
A line of light runs down the middle of the logo, then the two halves glide apart to reveal a tagline between them, its letters closing up as it fades in. Where there isn’t room side by side, as on a phone, it splits top and bottom instead.
animacious
How to use it
- Paste the snippet just before
</head>. - Wrap your logo (an
<img>, an inline<svg>, a wordmark or all of them) in an element withdata-logo-split-reveal. - Put the tagline in
data-tagline. It’s 0.36 of the logo’s font size; style.lsr-tag-into change it.
Options
Add any of these attributes to the same element.
data-direction | auto (side by side when there’s room, else top and bottom), horizontal or vertical. Default auto. |
data-duration | Time for the halves to part. Default 900 ms. |
data-loop | Part, hold, close up and repeat. |
data-hold | How long it stays open, when looping. Default 2200 ms. |
data-color | Colour of the line of light. Default the text colour. |
data-delay | Wait before it starts. |
It starts when scrolled into view. The halves are two copies of your logo, the second hidden from screen readers, so give the wrapper a role="img" and an aria-label that includes the tagline. With reduced motion it simply shows open.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Logo Split Reveal: a line of light runs down the middle of the logo,
// then the two halves slide apart to reveal a tagline between them. Loop
// it and they close up again. Where there isn't room side by side (a
// phone, say) the logo splits top and bottom instead.
// Usage: <div data-logo-split-reveal data-tagline="Animation made simple">
// <svg …>…</svg> Brand
// </div>
// Optional: data-direction ("auto" (default), "horizontal" or "vertical"),
// data-duration (ms for the halves to part, default 900), data-delay (ms
// before it starts), data-loop (part, hold, close and repeat), data-hold
// (ms it stays open when looping, default 2200), data-color (the line of
// light, default the text colour). Style the tagline with .lsr-tag.
var DEFAULTS = { duration: 900, hold: 2200 };
var css =
'.lsr{position:relative;display:inline-grid;vertical-align:middle}' +
'.lsr-half{grid-area:1/1;display:flex;align-items:center;justify-content:center;gap:inherit}' +
'.lsr-tag{position:absolute;left:50%;top:50%;width:max-content;transform:translate(-50%,-50%);text-align:center;pointer-events:none}' +
'.lsr-tag-in{display:block;font-size:.36em;font-weight:600;letter-spacing:.02em;line-height:1.25;opacity:0}' +
'.lsr-seam{position:absolute;left:50%;top:-8%;bottom:-8%;width:2px;margin-left:-1px;border-radius:2px;opacity:0;pointer-events:none;' +
'background:linear-gradient(transparent,currentColor 30%,currentColor 70%,transparent);box-shadow:0 0 12px currentColor}' +
'.lsr-v .lsr-seam{left:-6%;right:-6%;top:50%;bottom:auto;width:auto;height:2px;margin:-1px 0 0;background:linear-gradient(90deg,transparent,currentColor 30%,currentColor 70%,transparent)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
el.classList.add('lsr');
// Two copies of the logo, each clipped to one half.
var a = document.createElement('div');
a.className = 'lsr-half';
while (el.firstChild) a.appendChild(el.firstChild);
var b = a.cloneNode(true);
b.setAttribute('aria-hidden', 'true');
// Ids in the copy would clash with the original's.
Array.prototype.forEach.call(b.querySelectorAll('[id]'), function (n) { n.removeAttribute('id'); });
var tag = document.createElement('div');
tag.className = 'lsr-tag';
tag.innerHTML = '<span class="lsr-tag-in"></span>';
tag.firstChild.textContent = d.tagline || '';
var seam = document.createElement('div');
seam.className = 'lsr-seam';
seam.setAttribute('aria-hidden', 'true');
if (d.color) seam.style.color = d.color;
el.appendChild(a);
el.appendChild(b);
el.appendChild(tag);
el.appendChild(seam);
var s = { el: el, a: a, b: b, tag: tag, text: tag.firstChild, seam: seam, open: false, dir: d.direction || 'auto' };
el._lsr = s;
measure(s);
if ('ResizeObserver' in window) {
var last = 0;
new ResizeObserver(function () {
var w = (el.parentElement || document.body).clientWidth;
if (w === last) return;
last = w;
measure(s);
place(s);
}).observe(el.parentElement || document.body);
}
return s;
}
// Works out the direction, how far the halves part and how the tagline wraps.
function measure(s) {
var parent = s.el.parentElement || document.body;
var cs = getComputedStyle(parent);
var avail = parent.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
var text = s.text;
s.tag.style.maxWidth = '';
s.tag.style.whiteSpace = 'nowrap';
var w = s.a.offsetWidth;
var tw = s.tag.offsetWidth;
var pad = parseFloat(getComputedStyle(text).fontSize) * 0.9;
var dir = s.dir === 'vertical' || s.dir === 'horizontal' ? s.dir : (w + tw + pad * 2 <= avail ? 'horizontal' : 'vertical');
var room = dir === 'horizontal' ? avail - w - pad * 2 : avail;
if (tw > room) {
s.tag.style.whiteSpace = 'normal';
s.tag.style.maxWidth = Math.max(60, room) + 'px';
}
s.vertical = dir === 'vertical';
s.el.classList.toggle('lsr-v', s.vertical);
s.gap = s.vertical ? s.tag.offsetHeight + pad * 2 : s.tag.offsetWidth + pad * 2;
s.a.style.clipPath = s.vertical ? 'inset(-30% -30% 50% -30%)' : 'inset(-30% 50% -30% -30%)';
s.b.style.clipPath = s.vertical ? 'inset(50% -30% -30% -30%)' : 'inset(-30% -30% -30% 50%)';
}
function offsets(s, open) {
var g = open ? s.gap / 2 : 0;
return s.vertical ? [{ x: 0, y: -g }, { x: 0, y: g }] : [{ x: -g, y: 0 }, { x: g, y: 0 }];
}
function place(s) {
var o = offsets(s, s.open);
anime.utils.set(s.a, o[0]);
anime.utils.set(s.b, o[1]);
anime.utils.set(s.text, { opacity: s.open ? 1 : 0 });
}
function open(s, duration) {
s.open = true;
var o = offsets(s, true);
var tl = anime.createTimeline();
tl.add(s.seam, { opacity: [0, 1], scaleY: s.vertical ? 1 : [0, 1], scaleX: s.vertical ? [0, 1] : 1, duration: 380, ease: 'out(3)' })
.add(s.a, { x: o[0].x, y: o[0].y, duration: duration, ease: 'inOut(4)' }, '+=80')
.add(s.b, { x: o[1].x, y: o[1].y, duration: duration, ease: 'inOut(4)' }, '<<')
.add(s.seam, { opacity: 0, duration: duration * 0.6, ease: 'out(2)' }, '<<')
.add(s.text, { opacity: [0, 1], letterSpacing: ['0.4em', '0.02em'], scale: [0.92, 1], duration: duration, ease: 'out(3)' }, '-=' + duration * 0.55);
}
function close(s) {
s.open = false;
var o = offsets(s, false);
var tl = anime.createTimeline();
tl.add(s.text, { opacity: 0, scale: 0.94, duration: 320, ease: 'in(2)' })
.add(s.a, { x: o[0].x, y: o[0].y, duration: 650, ease: 'inOut(3)' }, '-=120')
.add(s.b, { x: o[1].x, y: o[1].y, duration: 650, ease: 'inOut(3)' }, '<<')
// A small bump as the halves meet.
.add(s.el, { scale: [1, 1.03, 1], duration: 380, ease: 'out(2)' });
}
function play(s) {
var d = s.el.dataset;
var duration = +d.duration || DEFAULTS.duration;
var hold = +d.hold || DEFAULTS.hold;
var loop = d.loop !== undefined && d.loop !== 'false';
var cycle = function () {
measure(s);
open(s, duration);
if (loop) setTimeout(function () {
close(s);
setTimeout(cycle, 1600);
}, duration + 500 + hold);
};
setTimeout(cycle, +d.delay || 300);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target._lsr);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-logo-split-reveal]').forEach(function (el) {
if (el._lsr) return;
var s = setup(el);
if (reduced()) { s.open = true; place(s); }
else io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-logo-split-reveal data-tagline="Animation, made simple"
role="img" aria-label="Animacious: Animation, made simple"
style="font-size: 56px; font-weight: 800; gap: 0.25em">
<img src="/mark.svg" alt="" width="56"> animacious
</div>