Offset Outline Ripple
Copies of a shape’s outline keep expanding outwards from it and fading away, like ripples that follow the shape’s own edge. A heart ripples in hearts and a rounded button ripples in rounded rectangles.
How to use it
- Paste the snippet just before
</head>. - Add
data-offset-outline-rippleto an inline<svg>: every filled shape in it ripples. Mark shapes withdata-rippleto ripple only those. - Or add it to an HTML element such as a button, badge or avatar, and the ripples follow its rounded corners.
Options
Add any of these attributes to the same element.
data-count | How many copies are on their way at once. Default 3. |
data-distance | How far they travel: px on HTML, viewBox units on SVG. Default 22 px, or a quarter of the shape on SVG. |
data-duration | Time for one copy to travel and fade. Default 2400 ms. |
data-color | Colour of the outlines. Default the shape’s stroke or fill. |
data-width | Outline width. Default 2 px. |
data-hover | Only ripple while pointed at or focused; a tap sends one round. |
The copies are scaled by just enough to push every edge out by the same distance, so simple shapes stay true. It pauses while off screen, and does nothing with reduced motion.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Offset Outline Ripple: copies of a shape's outline keep expanding outwards
// from it and fading, like ripples that follow the shape's own edge. Works on
// the shapes of an inline SVG (heart, star, pin...) and on HTML elements such
// as buttons, badges and avatars, where the ripples follow the border radius.
// Usage: <svg data-offset-outline-ripple viewBox="…">…filled shapes…</svg>
// <span data-offset-outline-ripple class="badge">Live</span>
// Optional: data-count (copies on the go at once, default 3),
// data-distance (how far they travel: px on HTML, viewBox units on SVG;
// default 22 px, or a quarter of the shape on SVG), data-duration (ms for
// one copy to travel and fade, default 2400), data-color (default the
// shape's stroke or fill), data-width (outline width in px, default 2),
// data-hover (only ripple while pointed at or tapped).
// In an SVG, mark shapes with data-ripple to ripple only those.
var DEFAULTS = { count: 3, distance: 22, duration: 2400, width: 2 };
var SHAPES = 'path, circle, ellipse, rect, polygon';
var SVGNS = 'http://www.w3.org/2000/svg';
var css = '.oor-ring{position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0}' +
'.oor-rings{pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function easeOut(t) { return 1 - Math.pow(1 - t, 2.2); }
function paint(v) { return v && v !== 'none' && v !== 'transparent' && v.indexOf('url(') < 0 && !/rgba\(.*,\s*0\)$/.test(v) ? v : ''; }
// SVG: a group of outline copies behind each shape, scaled about its centre
// by just enough to push every edge out by the same distance.
function setupSvg(svg, o) {
var marked = svg.querySelectorAll('[data-ripple]');
var shapes = Array.prototype.filter.call(marked.length ? marked : svg.querySelectorAll(SHAPES), function (s) {
return !s.closest('defs, clipPath, mask, .oor-rings');
});
svg.style.overflow = 'visible';
return shapes.map(function (s) {
var b = s.getBBox();
var cs = getComputedStyle(s);
var color = svg.dataset.color || paint(cs.stroke) || paint(cs.fill) || 'currentColor';
var g = document.createElementNS(SVGNS, 'g');
g.setAttribute('class', 'oor-rings');
g.setAttribute('aria-hidden', 'true');
var copies = [];
for (var i = 0; i < o.count; i++) {
var c = s.cloneNode(false);
c.removeAttribute('id');
c.removeAttribute('data-ripple');
c.removeAttribute('class');
c.removeAttribute('style');
c.setAttribute('fill', 'none');
c.setAttribute('stroke', color);
c.setAttribute('stroke-width', o.width);
c.setAttribute('vector-effect', 'non-scaling-stroke');
c.setAttribute('opacity', '0');
g.appendChild(c);
copies.push(c);
}
s.parentNode.insertBefore(g, s);
var dist = svg.dataset.distance ? +svg.dataset.distance : Math.min(b.width, b.height) * 0.25;
var cx = b.x + b.width / 2, cy = b.y + b.height / 2;
return {
copies: copies,
place: function (c, p) {
var off = dist * easeOut(p);
var sx = (b.width + 2 * off) / b.width, sy = (b.height + 2 * off) / b.height;
c.setAttribute('transform', 'translate(' + cx + ' ' + cy + ') scale(' + sx + ' ' + sy + ') translate(' + -cx + ' ' + -cy + ')');
c.setAttribute('opacity', Math.pow(1 - p, 1.5));
}
};
});
}
// HTML: rings that fill the element and use outline-offset, which follows
// the border radius, so the copies really are offset outlines.
function setupHtml(el, o) {
var cs = getComputedStyle(el);
if (cs.position === 'static') el.style.position = 'relative';
var color = el.dataset.color || paint(cs.backgroundColor) || cs.color;
var dist = el.dataset.distance ? +el.dataset.distance : DEFAULTS.distance;
var copies = [];
for (var i = 0; i < o.count; i++) {
var r = document.createElement('span');
r.className = 'oor-ring';
r.setAttribute('aria-hidden', 'true');
r.style.outline = o.width + 'px solid ' + color;
el.appendChild(r);
copies.push(r);
}
return [{
copies: copies,
place: function (c, p) {
c.style.outlineOffset = (dist * easeOut(p)).toFixed(2) + 'px';
c.style.opacity = Math.pow(1 - p, 1.5);
}
}];
}
function setup(el) {
var d = el.dataset;
var o = {
count: Math.max(1, +d.count || DEFAULTS.count),
duration: +d.duration || DEFAULTS.duration,
width: d.width !== undefined ? +d.width : DEFAULTS.width
};
var groups = el instanceof SVGSVGElement ? setupSvg(el, o) : setupHtml(el, o);
if (!groups.length) return;
var hoverOnly = d.hover !== undefined && d.hover !== 'false';
var visible = false, raf = 0, last = 0, clock = 0;
// A new copy sets off every duration / count ms while it's on; each one
// lives one duration, so `count` copies are reused in turn. In hover mode
// copies already on their way finish rather than vanishing.
var want = !hoverOnly, births = [], next = 0, turn = 0;
var step = o.duration / o.count;
function frame(now) {
raf = 0;
if (!visible) return;
clock += Math.min(50, now - (last || now));
last = now;
if (want && clock >= next) {
births[turn] = clock;
turn = (turn + 1) % o.count;
next = Math.max(next + step, clock - step * 0.5);
}
var alive = false;
for (var i = 0; i < o.count; i++) {
var p = births[i] === undefined ? 1 : (clock - births[i]) / o.duration;
if (p < 1) alive = true;
groups.forEach(function (g) {
var c = g.copies[i];
if (p < 1) { c._oorOn = true; g.place(c, p); }
else if (c._oorOn) { c._oorOn = false; g.place(c, 1); }
});
}
if (want || alive) raf = requestAnimationFrame(frame);
}
function wake() {
last = 0;
if (visible && !raf) raf = requestAnimationFrame(frame);
}
if (hoverOnly) {
var on = function () { if (!want) next = Math.max(next, clock); want = true; wake(); };
var off = function () { want = false; };
el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') on(); });
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') off(); });
el.addEventListener('focusin', on);
el.addEventListener('focusout', off);
// A tap sends one round of ripples.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse') return;
on();
clearTimeout(el._oorTap);
el._oorTap = setTimeout(off, o.duration / o.count * (o.count - 0.5));
});
}
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
wake();
}).observe(el);
}
function init() {
if (reduced()) return;
document.querySelectorAll('[data-offset-outline-ripple]').forEach(function (el) {
if (el._oor) return;
el._oor = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><svg data-offset-outline-ripple viewBox="0 0 100 100" width="120">
<circle cx="50" cy="50" r="24" fill="#8b5cf6"/>
</svg>
<!-- on a button: -->
<button data-offset-outline-ripple data-hover>Go live</button>