Fri 30 Oct · 09:00
Compass
import { useEffect, useRef, useState } from 'react';
const css = `
.card-compass {
display: grid;
justify-items: center;
gap: 8px;
width: 196px;
padding: 14px 14px 12px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-compass__dial { position: relative; width: 132px; height: 132px; }
.card-compass__dial svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.card-compass__ring { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.card-compass__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.card-compass__tick--major { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.card-compass__letter { fill: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; text-anchor: middle; dominant-baseline: central; }
.card-compass__letter--n { fill: light-dark(#373a40, #f1f2f3); }
.card-compass__north { fill: light-dark(#373a40, #f1f2f3); }
.card-compass__south { fill: light-dark(#cbced3, #393c41); }
.card-compass__hub { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.card-compass__read { color: light-dark(#373a40, #f1f2f3); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-compass__read small { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 500; margin-left: 6px; }
`;
const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
export function CompassCard() {
const dial = useRef<HTMLDivElement>(null);
const needle = useRef<SVGGElement>(null);
const target = useRef(0);
const [heading, setHeading] = useState(0);
// A damped spring toward the target bearing: it overshoots and settles, like a real needle.
useEffect(() => {
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let a = 0;
let v = 0;
let raf = 0;
let idle = 0;
const tick = (now: number) => {
// With no pointer around, the target drifts slowly so the needle still hunts.
if (performance.now() - idle > 2500) target.current = Math.sin(now / 2200) * 40 + 20;
let d = target.current - a;
d = ((d + 540) % 360) - 180; // the short way round
if (still) a = target.current;
else {
v = (v + d * 0.012) * 0.9;
a += v;
}
needle.current!.style.transform = `rotate(${a.toFixed(2)}deg)`;
setHeading(Math.round(((a % 360) + 360) % 360));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const move = (e: PointerEvent) => {
const r = dial.current!.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
// Bearing measured clockwise from up.
target.current = (Math.atan2(dx, -dy) * 180) / Math.PI;
idle = performance.now();
};
window.addEventListener('pointermove', move);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('pointermove', move);
};
}, []);
return (
<>
<style>
{css}
</style>
<div className="card-compass" role="group" aria-label={`Compass heading ${heading} degrees`}>
<div ref={dial} className="card-compass__dial" aria-hidden="true">
<svg viewBox="-66 -66 132 132">
<circle className="card-compass__ring" r={64} />
{Array.from({ length: 36 }, (_, i) => {
const a = (i * 10 * Math.PI) / 180;
const major = i % 9 === 0;
return (
<line
key={i}
className={`card-compass__tick${major ? ' card-compass__tick--major' : ''}`}
x1={(Math.sin(a) * 60).toFixed(2)}
y1={(-Math.cos(a) * 60).toFixed(2)}
x2={(Math.sin(a) * (major ? 52 : 56)).toFixed(2)}
y2={(-Math.cos(a) * (major ? 52 : 56)).toFixed(2)}
/>
);
})}
{['N', 'E', 'S', 'W'].map((l, i) => {
const a = (i * 90 * Math.PI) / 180;
return (
<text key={l} className={`card-compass__letter${l === 'N' ? ' card-compass__letter--n' : ''}`} x={(Math.sin(a) * 42).toFixed(2)} y={(-Math.cos(a) * 42).toFixed(2)}>
{l}
</text>
);
})}
<g ref={needle}>
<path className="card-compass__north" d="M0 -36 L5 0 L-5 0Z" />
<path className="card-compass__south" d="M0 36 L5 0 L-5 0Z" />
</g>
<circle className="card-compass__hub" r={4} />
</svg>
</div>
<div className="card-compass__read">
{heading}°<small>{DIRS[Math.round(heading / 45) % 8]}</small>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardCompass(el) on the mounted element instead.
<div class="card-compass" role="group" aria-label="Compass heading 0 degrees">
<div class="card-compass__dial" aria-hidden="true">
<svg viewBox="-66 -66 132 132">
<circle class="card-compass__ring" r="64"></circle>
<line class="card-compass__tick card-compass__tick--major" x1="0.00" y1="-60.00" x2="0.00" y2="-52.00"></line>
<line class="card-compass__tick" x1="10.42" y1="-59.09" x2="9.72" y2="-55.15"></line>
<line class="card-compass__tick" x1="20.52" y1="-56.38" x2="19.15" y2="-52.62"></line>
<line class="card-compass__tick" x1="30.00" y1="-51.96" x2="28.00" y2="-48.50"></line>
<line class="card-compass__tick" x1="38.57" y1="-45.96" x2="36.00" y2="-42.90"></line>
<line class="card-compass__tick" x1="45.96" y1="-38.57" x2="42.90" y2="-36.00"></line>
<line class="card-compass__tick" x1="51.96" y1="-30.00" x2="48.50" y2="-28.00"></line>
<line class="card-compass__tick" x1="56.38" y1="-20.52" x2="52.62" y2="-19.15"></line>
<line class="card-compass__tick" x1="59.09" y1="-10.42" x2="55.15" y2="-9.72"></line>
<line class="card-compass__tick card-compass__tick--major" x1="60.00" y1="-0.00" x2="52.00" y2="-0.00"></line>
<line class="card-compass__tick" x1="59.09" y1="10.42" x2="55.15" y2="9.72"></line>
<line class="card-compass__tick" x1="56.38" y1="20.52" x2="52.62" y2="19.15"></line>
<line class="card-compass__tick" x1="51.96" y1="30.00" x2="48.50" y2="28.00"></line>
<line class="card-compass__tick" x1="45.96" y1="38.57" x2="42.90" y2="36.00"></line>
<line class="card-compass__tick" x1="38.57" y1="45.96" x2="36.00" y2="42.90"></line>
<line class="card-compass__tick" x1="30.00" y1="51.96" x2="28.00" y2="48.50"></line>
<line class="card-compass__tick" x1="20.52" y1="56.38" x2="19.15" y2="52.62"></line>
<line class="card-compass__tick" x1="10.42" y1="59.09" x2="9.72" y2="55.15"></line>
<line class="card-compass__tick card-compass__tick--major" x1="0.00" y1="60.00" x2="0.00" y2="52.00"></line>
<line class="card-compass__tick" x1="-10.42" y1="59.09" x2="-9.72" y2="55.15"></line>
<line class="card-compass__tick" x1="-20.52" y1="56.38" x2="-19.15" y2="52.62"></line>
<line class="card-compass__tick" x1="-30.00" y1="51.96" x2="-28.00" y2="48.50"></line>
<line class="card-compass__tick" x1="-38.57" y1="45.96" x2="-36.00" y2="42.90"></line>
<line class="card-compass__tick" x1="-45.96" y1="38.57" x2="-42.90" y2="36.00"></line>
<line class="card-compass__tick" x1="-51.96" y1="30.00" x2="-48.50" y2="28.00"></line>
<line class="card-compass__tick" x1="-56.38" y1="20.52" x2="-52.62" y2="19.15"></line>
<line class="card-compass__tick" x1="-59.09" y1="10.42" x2="-55.15" y2="9.72"></line>
<line class="card-compass__tick card-compass__tick--major" x1="-60.00" y1="0.00" x2="-52.00" y2="0.00"></line>
<line class="card-compass__tick" x1="-59.09" y1="-10.42" x2="-55.15" y2="-9.72"></line>
<line class="card-compass__tick" x1="-56.38" y1="-20.52" x2="-52.62" y2="-19.15"></line>
<line class="card-compass__tick" x1="-51.96" y1="-30.00" x2="-48.50" y2="-28.00"></line>
<line class="card-compass__tick" x1="-45.96" y1="-38.57" x2="-42.90" y2="-36.00"></line>
<line class="card-compass__tick" x1="-38.57" y1="-45.96" x2="-36.00" y2="-42.90"></line>
<line class="card-compass__tick" x1="-30.00" y1="-51.96" x2="-28.00" y2="-48.50"></line>
<line class="card-compass__tick" x1="-20.52" y1="-56.38" x2="-19.15" y2="-52.62"></line>
<line class="card-compass__tick" x1="-10.42" y1="-59.09" x2="-9.72" y2="-55.15"></line>
<text class="card-compass__letter card-compass__letter--n" x="0.00" y="-42.00">N</text>
<text class="card-compass__letter" x="42.00" y="-0.00">E</text>
<text class="card-compass__letter" x="0.00" y="42.00">S</text>
<text class="card-compass__letter" x="-42.00" y="0.00">W</text>
<g>
<path class="card-compass__north" d="M0 -36 L5 0 L-5 0Z"></path>
<path class="card-compass__south" d="M0 36 L5 0 L-5 0Z"></path>
</g>
<circle class="card-compass__hub" r="4"></circle>
</svg>
</div>
<div class="card-compass__read">0°<small>N</small></div>
</div>.card-compass {
display: grid;
justify-items: center;
gap: 8px;
width: 196px;
padding: 14px 14px 12px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-compass__dial { position: relative; width: 132px; height: 132px; }
.card-compass__dial svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.card-compass__ring { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.card-compass__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.card-compass__tick--major { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.card-compass__letter { fill: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; text-anchor: middle; dominant-baseline: central; }
.card-compass__letter--n { fill: light-dark(#373a40, #f1f2f3); }
.card-compass__north { fill: light-dark(#373a40, #f1f2f3); }
.card-compass__south { fill: light-dark(#cbced3, #393c41); }
.card-compass__hub { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.card-compass__read { color: light-dark(#373a40, #f1f2f3); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-compass__read small { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 500; margin-left: 6px; }function initCardCompass(root) {
var dial = root.querySelector('.card-compass__dial');
var needle = root.querySelector('.card-compass__dial svg g');
var read = root.querySelector('.card-compass__read');
if (!dial || !needle || !read) return;
var DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var target = 0;
var idle = 0;
var a = 0;
var v = 0;
var raf = 0;
// A damped spring toward the target bearing: it overshoots and settles, like a real needle.
function tick(now) {
// With no pointer around, the target drifts slowly so the needle still hunts.
if (performance.now() - idle > 2500) target = Math.sin(now / 2200) * 40 + 20;
var d = (((target - a) + 540) % 360) - 180; // the short way round
if (still) a = target;
else {
v = (v + d * 0.012) * 0.9;
a += v;
}
needle.style.transform = 'rotate(' + a.toFixed(2) + 'deg)';
var heading = Math.round(((a % 360) + 360) % 360);
read.firstChild.textContent = heading + '°';
read.querySelector('small').textContent = DIRS[Math.round(heading / 45) % 8];
root.setAttribute('aria-label', 'Compass heading ' + heading + ' degrees');
raf = requestAnimationFrame(tick);
}
function move(e) {
var r = dial.getBoundingClientRect();
// Bearing measured clockwise from up.
target = (Math.atan2(e.clientX - (r.left + r.width / 2), -(e.clientY - (r.top + r.height / 2))) * 180) / Math.PI;
idle = performance.now();
}
window.addEventListener('pointermove', move);
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
window.removeEventListener('pointermove', move);
};
}
document.querySelectorAll('.card-compass').forEach(initCardCompass);Goal: Create a "Compass" card.
Visual behavior: A compass card whose needle swings toward the cursor anywhere on the page with a damped wobble, overshooting and settling like a real needle; left alone it hunts gently. The heading and direction read out below.
Technique:
- The dial is an SVG: 36 ticks (every ninth major), the cardinal letters and a two-tone needle in one group.
- A window-level pointermove sets the target bearing from the dial centre (atan2 measured clockwise from up).
- Each frame the needle's velocity gains a fraction of the shortest angle to the target and is damped by 0.9, which gives the overshoot and settle.
- After 2.5s without a pointer the target drifts slowly on a sine, so the needle keeps hunting; the readout rounds the angle and names the nearest of eight directions.
Accessibility: A group whose aria-label states the heading; the dial is aria-hidden. Under prefers-reduced-motion the needle points straight at the target.
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 "Compass" card.
Visual behavior: A compass card whose needle swings toward the cursor anywhere on the page with a damped wobble, overshooting and settling like a real needle; left alone it hunts gently. The heading and direction read out below.
Technique:
- The dial is an SVG: 36 ticks (every ninth major), the cardinal letters and a two-tone needle in one group.
- A window-level pointermove sets the target bearing from the dial centre (atan2 measured clockwise from up).
- Each frame the needle's velocity gains a fraction of the shortest angle to the target and is damped by 0.9, which gives the overshoot and settle.
- After 2.5s without a pointer the target drifts slowly on a sine, so the needle keeps hunting; the readout rounds the angle and names the nearest of eight directions.
Accessibility: A group whose aria-label states the heading; the dial is aria-hidden. Under prefers-reduced-motion the needle points straight at the target.
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
A compass card whose needle swings toward the cursor anywhere on the page with a damped wobble, overshooting and settling like a real needle; left alone it hunts gently. The heading and direction read out below.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- mouse tracking, spring, card
- Techniques
- svg, spring, raf, mouse tracking
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A group whose aria-label states the heading; the dial is aria-hidden. Under prefers-reduced-motion the needle points straight at the target.