Constellation
import { useEffect, useRef } from 'react';
const css = `
.btn-constellation {
position: relative;
display: grid;
place-items: center;
width: 230px;
height: 64px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 14px;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.05em;
cursor: pointer;
overflow: hidden;
transition: border-color 0.3s ease;
}
.btn-constellation:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-constellation:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-constellation canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-constellation__label {
position: relative;
padding: 3px 10px;
border-radius: 999px;
background: color-mix(in oklab, light-dark(#fbfcfd, #121417) 75%, transparent);
}
`;
const COUNT = 34;
const LINK = 50;
export function ConstellationButton() {
const btn = useRef<HTMLButtonElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const pointer = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const el = canvas.current!;
const ctx = el.getContext('2d')!;
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const dpr = Math.min(2, devicePixelRatio || 1);
const w = el.clientWidth;
const h = el.clientHeight;
el.width = w * dpr;
el.height = h * dpr;
ctx.scale(dpr, dpr);
const nodes = Array.from({ length: COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 12,
vy: (Math.random() - 0.5) * 12,
}));
let last = performance.now();
let raf = 0;
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
ctx.clearRect(0, 0, w, h);
// Canvas can't read CSS variables: take the ink from the button's own colour,
// every frame, so a theme switch recolours it.
const ink = getComputedStyle(btn.current!).color;
ctx.strokeStyle = ink;
ctx.fillStyle = ink;
const p = pointer.current;
for (const n of nodes) {
if (p) {
// Gentle pull toward the cursor, capped so the swarm never collapses onto it.
const dx = p.x - n.x;
const dy = p.y - n.y;
const d = Math.hypot(dx, dy) || 1;
if (d < 90) {
n.vx += (dx / d) * 30 * dt;
n.vy += (dy / d) * 30 * dt;
}
}
n.vx *= 0.99;
n.vy *= 0.99;
n.x += n.vx * dt;
n.y += n.vy * dt;
if (n.x < 0 || n.x > w) n.vx *= -1;
if (n.y < 0 || n.y > h) n.vy *= -1;
n.x = Math.max(0, Math.min(w, n.x));
n.y = Math.max(0, Math.min(h, n.y));
}
for (let i = 0; i < COUNT; i++) {
const a = nodes[i];
for (let j = i + 1; j < COUNT; j++) {
const b = nodes[j];
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < LINK) {
ctx.globalAlpha = (1 - d / LINK) * 0.45;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
if (p) {
const d = Math.hypot(a.x - p.x, a.y - p.y);
if (d < 80) {
ctx.globalAlpha = (1 - d / 80) * 0.9;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
}
}
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.arc(a.x, a.y, 1.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (!still) raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<button
ref={btn}
type="button"
className="btn-constellation"
onPointerMove={(e) => {
const r = btn.current!.getBoundingClientRect();
pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top };
}}
onPointerLeave={() => (pointer.current = null)}
>
<canvas ref={canvas} aria-hidden="true" />
<span className="btn-constellation__label">Connect</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnConstellation(el) on the mounted element instead.
<button type="button" class="btn-constellation">
<canvas aria-hidden="true"></canvas>
<span class="btn-constellation__label">Connect</span>
</button>.btn-constellation {
position: relative;
display: grid;
place-items: center;
width: 230px;
height: 64px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 14px;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.05em;
cursor: pointer;
overflow: hidden;
transition: border-color 0.3s ease;
}
.btn-constellation:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-constellation:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-constellation canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-constellation__label {
position: relative;
padding: 3px 10px;
border-radius: 999px;
background: color-mix(in oklab, light-dark(#fbfcfd, #121417) 75%, transparent);
}function initBtnConstellation(root) {
var canvas = root.querySelector('canvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var COUNT = 34;
var LINK = 50;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var dpr = Math.min(2, devicePixelRatio || 1);
var w = canvas.clientWidth;
var h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
var nodes = [];
for (var i = 0; i < COUNT; i++) {
nodes.push({ x: Math.random() * w, y: Math.random() * h, vx: (Math.random() - 0.5) * 12, vy: (Math.random() - 0.5) * 12 });
}
var pointer = null;
var last = performance.now();
var raf = 0;
function frame(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
ctx.clearRect(0, 0, w, h);
// A canvas can't read CSS colours: take the ink from the button every frame, so a theme switch recolours it.
var ink = getComputedStyle(root).color;
ctx.strokeStyle = ink;
ctx.fillStyle = ink;
nodes.forEach(function (n) {
if (pointer) {
// A gentle pull toward the cursor, so the swarm never collapses onto it.
var dx = pointer.x - n.x;
var dy = pointer.y - n.y;
var d = Math.hypot(dx, dy) || 1;
if (d < 90) {
n.vx += (dx / d) * 30 * dt;
n.vy += (dy / d) * 30 * dt;
}
}
n.vx *= 0.99;
n.vy *= 0.99;
n.x += n.vx * dt;
n.y += n.vy * dt;
if (n.x < 0 || n.x > w) n.vx *= -1;
if (n.y < 0 || n.y > h) n.vy *= -1;
n.x = Math.max(0, Math.min(w, n.x));
n.y = Math.max(0, Math.min(h, n.y));
});
for (var a = 0; a < COUNT; a++) {
var p = nodes[a];
for (var b = a + 1; b < COUNT; b++) {
var q = nodes[b];
var d = Math.hypot(p.x - q.x, p.y - q.y);
if (d < LINK) {
ctx.globalAlpha = (1 - d / LINK) * 0.45;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
}
}
if (pointer) {
var dp = Math.hypot(p.x - pointer.x, p.y - pointer.y);
if (dp < 80) {
ctx.globalAlpha = (1 - dp / 80) * 0.9;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(pointer.x, pointer.y);
ctx.stroke();
}
}
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.arc(p.x, p.y, 1.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (!still) raf = requestAnimationFrame(frame);
}
function move(e) {
var r = root.getBoundingClientRect();
pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
}
function leave() { pointer = null; }
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', leave);
raf = requestAnimationFrame(frame);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerleave', leave);
};
}
document.querySelectorAll('.btn-constellation').forEach(initBtnConstellation);Goal: Create a "Constellation" button.
Visual behavior: Drifting points of light fill the button and wire themselves together whenever they pass close. Move the cursor over it and nearby nodes reach out to it with lines and drift toward it.
Technique:
- A canvas behind the label (sized to the button at device pixel ratio) holds 34 nodes with small random velocities that bounce off the edges.
- Every frame, pairs closer than 50px are joined with a line whose alpha falls with distance; nodes within 80px of the cursor get a brighter line to it.
- Nodes within 90px of the cursor are pulled gently toward it, with damping so the swarm never collapses onto one point.
- The canvas reads its ink from the button's computed colour every frame, so it follows a theme change.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The canvas is aria-hidden. Under prefers-reduced-motion one still frame is drawn.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Constellation" button.
Visual behavior: Drifting points of light fill the button and wire themselves together whenever they pass close. Move the cursor over it and nearby nodes reach out to it with lines and drift toward it.
Technique:
- A canvas behind the label (sized to the button at device pixel ratio) holds 34 nodes with small random velocities that bounce off the edges.
- Every frame, pairs closer than 50px are joined with a line whose alpha falls with distance; nodes within 80px of the cursor get a brighter line to it.
- Nodes within 90px of the cursor are pulled gently toward it, with damping so the swarm never collapses onto one point.
- The canvas reads its ink from the button's computed colour every frame, so it follows a theme change.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The canvas is aria-hidden. Under prefers-reduced-motion one still frame is drawn.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Drifting points of light fill the button and wire themselves together whenever they pass close. Move the cursor over it and nearby nodes reach out to it with lines and drift toward it.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- hover, mouse tracking, continuous, ambient
- Techniques
- raf, mouse tracking, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. The canvas is aria-hidden. Under prefers-reduced-motion one still frame is drawn.