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
948991export 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
10061056export 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
10101095const 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