Skip to content

Commit 05de8ea

Browse files
committed
feat(matrix-spinner): add spin-cross preset, async-state example, and animation perf pass
- Add spin-cross preset (spin -> error cross) mirroring spin-check - Add Spin Cross and Async State (loading -> success/error) doc examples - Fix spin-check/spin-cross spin-phase speed to match spin-cw - Switch animation engine to a single shared rAF ticker with drift correction - Cache dot refs instead of querySelectorAll, memoize component, cache reduced-motion check - Regenerate registry JSON
1 parent 63b4a68 commit 05de8ea

3 files changed

Lines changed: 260 additions & 39 deletions

File tree

public/registry/care-ui/matrix-spinner/matrix-spinner.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/components/ui/matrix-spinner.tsx

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @name matrix-spinner
3-
* @description SVG diamond-lattice matrix spinner: 25 diamonds arranged as a 4×4 base grid interleaved with a 3×3 offset grid. Ships 55 named animation presets covering every UI state — loading, success, error, idle, transition — each driven by a frame array with per-dot opacity values. Zero runtime dependencies.
3+
* @description SVG diamond-lattice matrix spinner: 25 diamonds arranged as a 4×4 base grid interleaved with a 3×3 offset grid. Ships 56 named animation presets covering every UI state — loading, success, error, idle, transition — each driven by a frame array with per-dot opacity values. Zero runtime dependencies.
44
* @type registry:ui
55
*/
66

@@ -854,7 +854,9 @@ function genSpinCw(): number[][] {
854854
// Checkmark: short left leg 8(0,4)→22(1,5)→13(2,6)
855855
// long right leg 13(2,6)→23(3,5)→10(4,4)→21(5,3)→7(6,2)
856856
// Dots 10,21,23 are shared — they anchor the smooth morph from spin to check.
857-
function genSpinCheck(): number[][] {
857+
// Spin phase runs at spin-cw's pace (11ms/frame) so it doesn't look laggy next to it;
858+
// the morph phase holds longer (70ms/frame) so the dissolve into the checkmark reads clearly.
859+
function genSpinCheck(): { frames: number[][]; intervals: number[] } {
858860
const ring = [17, 6, 21, 10, 23, 9, 19, 5];
859861
const check = [8, 22, 13, 23, 10, 21, 7];
860862
const n = ring.length;
@@ -876,7 +878,42 @@ function genSpinCheck(): number[][] {
876878
frames.push(sets(rest, [check, MID * 1.4]));
877879
frames.push(sets(rest, [check, HI]));
878880

879-
return frames;
881+
const intervals = [...Array(12).fill(11), 70, 70, 70, 90];
882+
return { frames, intervals };
883+
}
884+
885+
// ── spin-cross: spins CW ×1.5, morphs to error cross (X), holds — 16 frames ──
886+
// Spin ring: [17,6,21,10,23,9,19,5] (inner ring)
887+
// Cross diagonals: TL-BR [0,16,5,20,10,24,15] and TR-BL [3,18,6,20,9,22,12]
888+
// Dot 20 (centre) is shared by both diagonals — anchors the morph from spin to cross.
889+
// Spin phase runs at spin-cw's pace (11ms/frame) so it doesn't look laggy next to it;
890+
// the morph phase holds longer (70ms/frame) so the dissolve into the cross reads clearly.
891+
function genSpinCross(): { frames: number[][]; intervals: number[] } {
892+
const ring = [17, 6, 21, 10, 23, 9, 19, 5];
893+
const diag1 = [0, 16, 5, 20, 10, 24, 15];
894+
const diag2 = [3, 18, 6, 20, 9, 22, 12];
895+
const cross = Array.from(new Set([...diag1, ...diag2]));
896+
const n = ring.length;
897+
const rest = fill(DIM);
898+
const frames: number[][] = [];
899+
900+
// Phase 1: 1.5 rotations — 12 frames
901+
for (let f = 0; f < 12; f++) {
902+
const a = fill(DIM);
903+
a[ring[f % n]] = HI;
904+
a[ring[(f - 1 + n) % n]] = MID;
905+
a[ring[(f - 2 + n) % n]] = MID * 0.45;
906+
frames.push(a);
907+
}
908+
909+
// Phase 2: morph spin → cross — 4 frames
910+
frames.push(sets(rest, [cross, MID * 0.5]));
911+
frames.push(sets(rest, [cross, MID]));
912+
frames.push(sets(rest, [cross, MID * 1.4]));
913+
frames.push(sets(rest, [cross, HI]));
914+
915+
const intervals = [...Array(12).fill(11), 70, 70, 70, 90];
916+
return { frames, intervals };
880917
}
881918

882919
// ── spin-fall: spins CW ×1.5, directly falls — pixels rain randomly to bottom and settle — 29 frames
@@ -943,7 +980,13 @@ function genSpinFall(): number[][] {
943980

944981
// ─── Preset registry ──────────────────────────────────────────────────────────
945982

946-
type Preset = { frames: readonly number[][]; interval: number; loop?: boolean };
983+
type Preset = {
984+
frames: readonly number[][];
985+
interval: number;
986+
loop?: boolean;
987+
/** Optional per-frame duration (ms), indexed by current frame. Falls back to `interval`. */
988+
intervals?: readonly number[];
989+
};
947990

948991
export const MATRIX_SPINNERS: Record<string, Preset> = {
949992
"center-expand": { frames: genCenterExpand(), interval: 120 },
@@ -997,14 +1040,56 @@ export const MATRIX_SPINNERS: Record<string, Preset> = {
9971040
"grid-float-soft": { frames: genGridFloatSoft(), interval: 150 },
9981041
"diamond-pulse-soft": { frames: genDiamondPulseSoft(), interval: 180 },
9991042
"spin-cw": { frames: genSpinCw(), interval: 11 },
1000-
"spin-check": { frames: genSpinCheck(), interval: 30, loop: false },
1043+
"spin-check": (() => {
1044+
const { frames, intervals } = genSpinCheck();
1045+
return { frames, interval: 30, loop: false, intervals };
1046+
})(),
1047+
"spin-cross": (() => {
1048+
const { frames, intervals } = genSpinCross();
1049+
return { frames, interval: 30, loop: false, intervals };
1050+
})(),
10011051
"spin-fall": { frames: genSpinFall(), interval: 30, loop: false },
10021052
"heart-pulse": { frames: genHeartPulse(), interval: 150 },
10031053
"heart-plus": { frames: genHeartPlus(), interval: 140 },
10041054
};
10051055

10061056
export type MatrixSpinnerName = keyof typeof MATRIX_SPINNERS;
10071057

1058+
// ─── Shared animation driver ──────────────────────────────────────────────────
1059+
//
1060+
// A single requestAnimationFrame loop drives every mounted spinner instead of
1061+
// each instance running its own rAF loop, so CPU/GPU cost stays flat no matter
1062+
// how many spinners are on screen (e.g. a docs page rendering 50+ presets).
1063+
// `prefers-reduced-motion` is also read once and cached, instead of creating a
1064+
// MediaQueryList per instance.
1065+
1066+
type Ticker = (now: number) => void;
1067+
1068+
const tickers = new Set<Ticker>();
1069+
let tickerFrame: number | null = null;
1070+
1071+
function runTickers(now: number) {
1072+
for (const tick of tickers) tick(now);
1073+
tickerFrame = tickers.size > 0 ? requestAnimationFrame(runTickers) : null;
1074+
}
1075+
1076+
function subscribeTicker(tick: Ticker): () => void {
1077+
tickers.add(tick);
1078+
if (tickerFrame === null) tickerFrame = requestAnimationFrame(runTickers);
1079+
return () => {
1080+
tickers.delete(tick);
1081+
};
1082+
}
1083+
1084+
let prefersReducedMotion = false;
1085+
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
1086+
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
1087+
prefersReducedMotion = mql.matches;
1088+
mql.addEventListener("change", (e) => {
1089+
prefersReducedMotion = e.matches;
1090+
});
1091+
}
1092+
10081093
// ─── Size variants ────────────────────────────────────────────────────────────
10091094

10101095
const SIZES = {
@@ -1036,41 +1121,45 @@ function MatrixSpinner({
10361121
...props
10371122
}: MatrixSpinnerProps) {
10381123
const preset = MATRIX_SPINNERS[name];
1039-
const svgRef = React.useRef<SVGSVGElement>(null);
1040-
const transitionMs = Math.round(preset.interval * 0.8);
1124+
const rectRefs = React.useRef<(SVGRectElement | null)[]>([]);
1125+
const fastestInterval = preset.intervals
1126+
? Math.min(...preset.intervals)
1127+
: preset.interval;
1128+
const transitionMs = Math.round(fastestInterval * 0.8);
10411129

10421130
React.useEffect(() => {
1043-
const svg = svgRef.current;
1044-
if (!svg) return;
1045-
const mql =
1046-
typeof window !== "undefined"
1047-
? window.matchMedia("(prefers-reduced-motion: reduce)")
1048-
: null;
1049-
if (mql?.matches) return;
1050-
const rects = Array.from(
1051-
svg.querySelectorAll<SVGRectElement>("[data-dot]")
1052-
);
1131+
if (prefersReducedMotion) return;
1132+
const rects = rectRefs.current;
10531133
let frame = 0;
1054-
if (preset.loop === false) {
1055-
const id = setInterval(() => {
1056-
frame++;
1057-
if (frame >= preset.frames.length) {
1058-
clearInterval(id);
1059-
return;
1060-
}
1061-
const opacities = preset.frames[frame];
1062-
for (let i = 0; i < rects.length; i++)
1063-
rects[i].style.opacity = String(opacities[i]);
1064-
}, preset.interval);
1065-
return () => clearInterval(id);
1066-
}
1067-
const id = setInterval(() => {
1068-
frame = (frame + 1) % preset.frames.length;
1134+
let dueAt: number | null = null;
1135+
1136+
const tick = (now: number) => {
1137+
dueAt ??= now;
1138+
if (now < dueAt) return;
1139+
1140+
const duration = preset.intervals?.[frame] ?? preset.interval;
1141+
dueAt += duration;
1142+
// Catch up in one step if we fell far behind instead of drifting.
1143+
if (dueAt < now) dueAt = now + duration;
1144+
1145+
frame =
1146+
preset.loop === false
1147+
? frame + 1
1148+
: (frame + 1) % preset.frames.length;
1149+
if (preset.loop === false && frame >= preset.frames.length) {
1150+
unsubscribe();
1151+
return;
1152+
}
1153+
10691154
const opacities = preset.frames[frame];
1070-
for (let i = 0; i < rects.length; i++)
1071-
rects[i].style.opacity = String(opacities[i]);
1072-
}, preset.interval);
1073-
return () => clearInterval(id);
1155+
for (let i = 0; i < rects.length; i++) {
1156+
const rect = rects[i];
1157+
if (rect) rect.style.opacity = String(opacities[i]);
1158+
}
1159+
};
1160+
1161+
const unsubscribe = subscribeTicker(tick);
1162+
return unsubscribe;
10741163
}, [name, preset]);
10751164

10761165
const opacities = preset.frames[0];
@@ -1087,7 +1176,6 @@ function MatrixSpinner({
10871176
{...props}
10881177
>
10891178
<svg
1090-
ref={svgRef}
10911179
width={SVG_W}
10921180
height={SVG_H}
10931181
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
@@ -1103,6 +1191,9 @@ function MatrixSpinner({
11031191
return (
11041192
<rect
11051193
key={`b-${idx}`}
1194+
ref={(el) => {
1195+
rectRefs.current[idx] = el;
1196+
}}
11061197
data-dot
11071198
x={x}
11081199
y={y}
@@ -1112,6 +1203,7 @@ function MatrixSpinner({
11121203
fill="currentColor"
11131204
style={{
11141205
opacity: opacities[idx],
1206+
willChange: "opacity",
11151207
transition: `opacity ${transitionMs}ms cubic-bezier(0.23, 1, 0.32, 1)`,
11161208
}}
11171209
/>
@@ -1128,6 +1220,9 @@ function MatrixSpinner({
11281220
return (
11291221
<rect
11301222
key={`o-${idx}`}
1223+
ref={(el) => {
1224+
rectRefs.current[idx] = el;
1225+
}}
11311226
data-dot
11321227
x={x}
11331228
y={y}
@@ -1137,6 +1232,7 @@ function MatrixSpinner({
11371232
fill="currentColor"
11381233
style={{
11391234
opacity: opacities[idx],
1235+
willChange: "opacity",
11401236
transition: `opacity ${transitionMs}ms cubic-bezier(0.23, 1, 0.32, 1)`,
11411237
}}
11421238
/>
@@ -1149,4 +1245,7 @@ function MatrixSpinner({
11491245
);
11501246
}
11511247

1152-
export { MatrixSpinner };
1248+
const MemoizedMatrixSpinner = React.memo(MatrixSpinner);
1249+
MemoizedMatrixSpinner.displayName = "MatrixSpinner";
1250+
1251+
export { MemoizedMatrixSpinner as MatrixSpinner };

0 commit comments

Comments
 (0)