Loaders
NewtonCradle
Chrome balls on V strings under a two-rail frame on a walnut base. The end ball swings in, the knock runs through the still balls, the far ball swings out.
Download
- React
- Tailwind CSS v4
- motion
components/ui/newton-cradle.tsx
"use client";
import { useEffect, useId, useState } from "react";
import { useReducedMotion } from "motion/react";
type NewtonCradleProps = {
/** Number of balls. */
balls?: number;
/** How many balls swing at each end. */
lifted?: number;
/** Colour of the wooden base. */
wood?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Engraved on the base plate and read out by screen readers. */
label?: string;
className?: string;
};
const CX = 160;
const TOP = 30; // Height the strings hang from.
const LENGTH = 124;
const R = 18;
const REACH = 0.38; // Swing in radians.
const REST = 0.09;
/* NewtonCradle: chrome balls on V strings under a two-rail frame on a walnut base. The end ball swings in, the knock runs through the still balls, the far ball swings out. */
export function NewtonCradle({ balls = 5, lifted = 1, wood = "#6b3f22", speed = 1, label = "Loading", className = "" }: NewtonCradleProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const [clock, setClock] = useState(REST);
useEffect(() => {
if (reduce) return;
const seconds = 1.9 / speed;
const start = performance.now() - REST * seconds * 1000;
let raf = 0;
const tick = (now: number) => {
setClock(((now - start) / (seconds * 1000)) % 1);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [reduce, speed]);
const p = reduce ? REST : clock;
const swing = REACH * Math.cos(p * Math.PI * 2);
const since = (p % 0.5) - 0.25; // Time after the last knock, which comes at a quarter and three quarters.
const shake = since >= 0 ? 0.9 * Math.exp(-since * 26) * Math.sin(since * 120) : 0;
const lift = Math.min(lifted, Math.floor(balls / 2));
const spots = Array.from({ length: balls }, (_, i) => {
const home = CX + (i - (balls - 1) / 2) * R * 2;
let a = 0;
if (i >= balls - lift) a = Math.max(swing, 0);
if (i < lift) a = Math.min(swing, 0);
const still = i >= lift && i < balls - lift;
return {
home,
x: home + LENGTH * Math.sin(a) + (still ? shake : 0),
y: TOP + LENGTH * Math.cos(a),
};
});
return (
<div role="status" aria-label={label} className={`w-80 ${className}`}>
<svg viewBox="0 0 320 226" className="block w-full" aria-hidden>
<defs>
<radialGradient id={`${id}b`} cx="0.36" cy="0.3" r="0.8">
<stop offset="0" stopColor="#fff" />
<stop offset="0.24" stopColor="#d7dde3" />
<stop offset="0.58" stopColor="#6c757d" />
<stop offset="0.86" stopColor="#1d2125" />
<stop offset="1" stopColor="#616a72" />
</radialGradient>
<linearGradient id={`${id}r`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#f1f4f6" />
<stop offset="0.5" stopColor="#88919a" />
<stop offset="1" stopColor="#3b4147" />
</linearGradient>
<linearGradient id={`${id}p`} x1="0" x2="1">
<stop offset="0" stopColor="#8d8f94" />
<stop offset="0.3" stopColor="#f1f4f6" />
<stop offset="1" stopColor="#5a6168" />
</linearGradient>
<linearGradient id={`${id}w`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#fff" stopOpacity="0.2" />
<stop offset="0.2" stopColor="#fff" stopOpacity="0" />
<stop offset="1" stopColor="#000" stopOpacity="0.5" />
</linearGradient>
<linearGradient id={`${id}h`} x1="0" x2="1">
<stop offset="0" stopColor="#c99a45" />
<stop offset="0.5" stopColor="#f6dfa2" />
<stop offset="1" stopColor="#a87a30" />
</linearGradient>
</defs>
{/* Back rail first, so strings and balls pass in front of it. */}
<path d="M34 186 V30 Q34 22 42 22 H278 Q286 22 286 30 V186" fill="none" stroke={`url(#${id}p)`} strokeWidth="5" />
<rect x="6" y="186" width="308" height="30" rx="7" fill={wood} />
<rect x="6" y="186" width="308" height="30" rx="7" fill={`url(#${id}w)`} />
{spots.map((s, i) => {
const high = (s.y - TOP) / LENGTH;
return <ellipse key={i} cx={s.x} cy="192" rx={R * (0.3 + high * 0.6)} ry="2" fill="#000" opacity={0.28 * high ** 4} />;
})}
<rect x="118" y="196" width="84" height="13" rx="2" fill={`url(#${id}h)`} />
<text x="160" y="205.6" textAnchor="middle" fontFamily="ui-monospace,monospace" fontSize="8.5" letterSpacing="2.4" fill="#3c2a10">
{label.toUpperCase()}
</text>
{spots.map((s, i) => (
<g key={i} stroke="#d9dee3" strokeOpacity="0.55" strokeWidth="0.9">
<line x1={s.home - 5} y1="35" x2={s.x} y2={s.y - R} />
<line x1={s.home + 5} y1="24" x2={s.x} y2={s.y - R} />
</g>
))}
{spots.map((s, i) => (
<g key={i}>
<circle cx={s.x} cy={s.y} r={R} fill={`url(#${id}b)`} />
<path d={`M${s.x - R * 0.86} ${s.y + 3} Q${s.x} ${s.y + R * 0.72} ${s.x + R * 0.86} ${s.y + 3}`} fill="none" stroke="#fff" strokeOpacity="0.2" strokeWidth="1.4" />
<rect x={s.x - 1.6} y={s.y - R - 2.4} width="3.2" height="3.4" rx="1" fill="#b9c0c7" />
</g>
))}
{/* Front rail last, it passes in front of everything. */}
<path d="M20 190 V43 Q20 35 28 35 H292 Q300 35 300 43 V190" fill="none" stroke={`url(#${id}r)`} strokeWidth="7" strokeLinecap="round" />
{[20, 300].map((x) => (
<rect key={x} x={x - 8} y="186" width="16" height="4.6" rx="1.6" fill={`url(#${id}h)`} />
))}
</svg>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/newton-cradle.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/newton-cradle-demo.tsx
import { NewtonCradle } from "@/components/ui/newton-cradle"; export default function NewtonCradleExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_40%,#2b3038,#0d0f12_75%)] p-2"> <NewtonCradle className="w-[min(130cqh,92cqw,420px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| balls | number | 5 | Number of balls. |
| lifted | number | 1 | How many balls swing at each end. |
| wood | string | "#6b3f22" | Colour of the wooden base. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "Loading" | Engraved on the base plate and read out by screen readers (role=status). |
| className | string | "w-80" | Set the width here. Everything scales with it. |