Low-poly Gem
A cut gem turns slowly on the spot, bobbing gently above its shadow. Every flat facet catches the light as it comes round, flashing bright and then darkening again, and little star-shaped glints twinkle off the brightest corners. Drag or flick it to give it a spin.
How to use it
- Paste the snippet just before
</head>. - Add
data-low-poly-gemto a box with a height.
Options
Add any of these attributes to the same element.
data-color | The gem’s colour. Default violet, #8b5cf6. |
data-facets | How many sides it has round the middle, from 6 to 16. Default 10. |
data-speed | How fast it turns. Default 1; 0 stands it still. |
data-sparkle="false" | No twinkling glints. |
data-drag="false" | Don’t let people spin it by dragging. |
Drawn on a canvas with flat shading, so it stays crisp at any size. It’s paused when off screen; with reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Low-poly Gem: a faceted, brilliant-cut gem turns slowly on the spot. Each
// flat facet catches the light as it comes round, flashing bright, and
// little sparkles glint off the brightest corners. Drag or flick to spin it.
// Usage: <div data-low-poly-gem style="height: 320px"></div>
// Optional: data-color (the gem's colour, default "#8b5cf6"), data-facets
// (sides round the gem, 6 to 16, default 10), data-speed (how fast it turns,
// default 1), data-sparkle="false" (no glints), data-drag="false".
var DEFAULTS = { color: '#8b5cf6', facets: 10, speed: 1 };
var css =
'[data-low-poly-gem]{position:relative;overflow:hidden;touch-action:pan-y}' +
'.lpg-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
var n = parseInt(hex, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
// The gem: a table on top, a crown down to the girdle, then a pavilion
// tapering to a point. y is up. Faces are listed anticlockwise from outside.
function gem(n) {
var V = [], F = [], i, a, step = Math.PI * 2 / n;
function ring(r, y, off) {
var start = V.length;
for (i = 0; i < n; i++) { a = (i + off) * step; V.push([Math.cos(a) * r, y, Math.sin(a) * r]); }
return start;
}
var T = ring(0.56, 0.5, 0.5), G = ring(1, 0.14, 0), H = ring(1, 0, 0), P = ring(0.52, -0.6, 0.5);
V.push([0, -1.12, 0]);
var C = V.length - 1;
var top = [];
for (i = n - 1; i >= 0; i--) top.push(T + i);
F.push(top);
for (i = 0; i < n; i++) {
var j = (i + 1) % n;
F.push([T + i, G + j, G + i]);
F.push([T + i, T + j, G + j]);
F.push([G + i, G + j, H + j, H + i]);
F.push([H + i, H + j, P + i]);
F.push([P + i, H + j, P + j]);
F.push([P + i, P + j, C]);
}
return { v: V, f: F };
}
function sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function norm(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function setup(el) {
var d = el.dataset;
var base = rgb(d.color || DEFAULTS.color);
var n = Math.max(6, Math.min(16, Math.round(+d.facets || DEFAULTS.facets)));
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var sparkle = d.sparkle !== 'false';
var G = gem(n);
var canvas = document.createElement('canvas');
canvas.className = 'lpg-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, dpr = 1;
var s = { yaw: 0.3, t: 0, v: 0, dragging: false };
// Light from the upper left, a little in front; we look from +z.
var L = norm([-0.5, 0.75, 0.6]);
var HALF = norm([L[0], L[1], L[2] + 1]);
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
function shade(k) {
return 'rgb(' + base.map(function (c) {
return Math.round(k < 1 ? c * k : c + (255 - c) * Math.min(1, k - 1));
}).join(',') + ')';
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var r = Math.min(w * 0.36, h * 0.3);
var ox = w / 2, oy = h / 2 - r * 0.02;
var bob = Math.sin(s.t * 1.6) * r * 0.04;
var yaw = s.yaw, cy = Math.cos(yaw), sy = Math.sin(yaw);
var pitch = 0.32 + Math.sin(s.t * 0.7) * 0.08, cp = Math.cos(pitch), sp = Math.sin(pitch);
var roll = Math.sin(s.t * 0.5) * 0.08, cr = Math.cos(roll), sr = Math.sin(roll);
function rot(p) {
var x = p[0] * cy + p[2] * sy, z = -p[0] * sy + p[2] * cy, y = p[1];
var y2 = y * cp + z * sp, z2 = -y * sp + z * cp;
return [x * cr - y2 * sr, x * sr + y2 * cr, z2];
}
var P = G.v.map(rot);
function scr(p) { var k = 5 / (5 - p[2]); return [ox + p[0] * r * k, oy - p[1] * r * k + bob]; }
var S = P.map(scr);
// Shadow on the ground, shrinking a touch as the gem bobs up.
ctx.save();
ctx.translate(ox, oy + r * 1.42);
ctx.scale(1, 0.22);
var sr2 = r * (0.95 - bob / r * 0.8);
var g = ctx.createRadialGradient(0, 0, 0, 0, 0, sr2);
g.addColorStop(0, 'rgba(40,10,80,.28)');
g.addColorStop(1, 'rgba(40,10,80,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, sr2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
var glints = [];
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(0.6, r / 110);
G.f.forEach(function (f) {
var nrm = norm(cross(sub(P[f[1]], P[f[0]]), sub(P[f[2]], P[f[0]])));
if (nrm[2] <= 0) return; // the gem is convex: back faces are simply hidden
var lam = Math.max(0, dot(nrm, L));
var spec = Math.pow(Math.max(0, dot(nrm, HALF)), 24);
// A fake "inner fire": facets pick up light or dark from what they mirror.
var env = Math.sin(nrm[0] * 5 + nrm[1] * 3 + s.yaw * 0.6) * 0.12;
var k = 0.55 + lam * 0.6 + env + spec * 1.1;
ctx.fillStyle = shade(k);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.18 + spec * 0.6).toFixed(3) + ')';
ctx.beginPath();
f.forEach(function (i, m) { if (m) ctx.lineTo(S[i][0], S[i][1]); else ctx.moveTo(S[i][0], S[i][1]); });
ctx.closePath();
ctx.fill();
ctx.stroke();
if (spec > 0.55) glints.push({ p: S[f[0]], a: spec });
});
if (!sparkle) return;
// Four-pointed glints that twinkle on the brightest facets' corners.
glints.slice(0, 3).forEach(function (gl, i) {
var tw = 0.5 + 0.5 * Math.sin(s.t * 6 + i * 2.1);
var size = r * 0.16 * gl.a * tw;
if (size < 1) return;
ctx.save();
ctx.translate(gl.p[0], gl.p[1]);
ctx.rotate(s.t * 0.8);
ctx.fillStyle = 'rgba(255,255,255,' + (0.9 * tw).toFixed(3) + ')';
ctx.beginPath();
ctx.moveTo(0, -size);
ctx.quadraticCurveTo(0, 0, size, 0);
ctx.quadraticCurveTo(0, 0, 0, size);
ctx.quadraticCurveTo(0, 0, -size, 0);
ctx.quadraticCurveTo(0, 0, 0, -size);
ctx.fill();
ctx.restore();
});
}
resize();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
if (reduced()) return;
if (d.drag !== 'false') {
el.style.cursor = 'grab';
var lx = 0, lt = 0;
el.addEventListener('pointerdown', function (e) {
s.dragging = true;
s.v = 0;
lx = e.clientX; lt = performance.now();
el.setPointerCapture(e.pointerId);
el.style.cursor = 'grabbing';
});
el.addEventListener('pointermove', function (e) {
if (!s.dragging) return;
var now = performance.now(), dx = (e.clientX - lx) * 0.012;
s.yaw += dx;
s.v = dx / Math.max(8, now - lt) * 1000;
lx = e.clientX; lt = now;
});
var end = function () { s.dragging = false; el.style.cursor = 'grab'; };
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) / 1000 : 0.016;
last = now;
s.t += dt;
if (!s.dragging) {
s.yaw += (0.55 * speed + s.v) * dt;
s.v *= Math.pow(0.05, dt);
if (Math.abs(s.v) < 0.02) s.v = 0;
}
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-low-poly-gem]').forEach(function (el) {
if (el._lpg) return;
el._lpg = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-low-poly-gem style="height: 320px"></div>
<!-- or a ruby: -->
<div data-low-poly-gem data-color="#e11d48" style="height: 320px"></div>