@moonarc/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/package.json +228 -0
  4. package/src/components/Accordion.astro +110 -0
  5. package/src/components/Accordion.meta.ts +33 -0
  6. package/src/components/AnimatedBeam.astro +217 -0
  7. package/src/components/AnimatedBeam.meta.ts +39 -0
  8. package/src/components/AnimatedIcons.astro +193 -0
  9. package/src/components/AnimatedIcons.meta.ts +34 -0
  10. package/src/components/AnimatedList.astro +305 -0
  11. package/src/components/AnimatedList.meta.ts +36 -0
  12. package/src/components/AnimatedSelect.astro +227 -0
  13. package/src/components/AnimatedSelect.meta.ts +38 -0
  14. package/src/components/ArrowButton.astro +133 -0
  15. package/src/components/ArrowButton.meta.ts +32 -0
  16. package/src/components/Aurora.astro +188 -0
  17. package/src/components/Aurora.meta.ts +36 -0
  18. package/src/components/AvatarStack.astro +205 -0
  19. package/src/components/AvatarStack.meta.ts +37 -0
  20. package/src/components/BarGrow.astro +123 -0
  21. package/src/components/BarGrow.meta.ts +42 -0
  22. package/src/components/BorderBeam.astro +75 -0
  23. package/src/components/BorderBeam.meta.ts +33 -0
  24. package/src/components/BrowserFrame.astro +172 -0
  25. package/src/components/BrowserFrame.meta.ts +36 -0
  26. package/src/components/BubbleText.astro +90 -0
  27. package/src/components/BubbleText.meta.ts +38 -0
  28. package/src/components/CardStack.astro +79 -0
  29. package/src/components/CardStack.meta.ts +33 -0
  30. package/src/components/Carousel.astro +147 -0
  31. package/src/components/Carousel.meta.ts +35 -0
  32. package/src/components/CheckDraw.astro +159 -0
  33. package/src/components/CheckDraw.meta.ts +37 -0
  34. package/src/components/CircularText.astro +103 -0
  35. package/src/components/CircularText.meta.ts +38 -0
  36. package/src/components/CodeTabs.astro +213 -0
  37. package/src/components/CodeTabs.meta.ts +36 -0
  38. package/src/components/CommandPalette.astro +363 -0
  39. package/src/components/CommandPalette.meta.ts +40 -0
  40. package/src/components/Confetti.astro +143 -0
  41. package/src/components/Confetti.meta.ts +40 -0
  42. package/src/components/CopyButton.astro +189 -0
  43. package/src/components/CopyButton.meta.ts +41 -0
  44. package/src/components/CountUp.astro +134 -0
  45. package/src/components/CountUp.meta.ts +40 -0
  46. package/src/components/CountdownFlip.astro +244 -0
  47. package/src/components/CountdownFlip.meta.ts +39 -0
  48. package/src/components/CounterPreloader.astro +122 -0
  49. package/src/components/CounterPreloader.meta.ts +34 -0
  50. package/src/components/Cursor.astro +177 -0
  51. package/src/components/Cursor.meta.ts +37 -0
  52. package/src/components/CursorGrid.astro +113 -0
  53. package/src/components/CursorGrid.meta.ts +37 -0
  54. package/src/components/Curtain.astro +305 -0
  55. package/src/components/Curtain.meta.ts +38 -0
  56. package/src/components/Dialog.astro +156 -0
  57. package/src/components/Dialog.meta.ts +37 -0
  58. package/src/components/Dock.astro +193 -0
  59. package/src/components/Dock.meta.ts +35 -0
  60. package/src/components/DotPattern.astro +58 -0
  61. package/src/components/DotPattern.meta.ts +32 -0
  62. package/src/components/DottedMap.astro +153 -0
  63. package/src/components/DottedMap.meta.ts +38 -0
  64. package/src/components/Drawer.astro +196 -0
  65. package/src/components/Drawer.meta.ts +35 -0
  66. package/src/components/DynamicIsland.astro +122 -0
  67. package/src/components/DynamicIsland.meta.ts +35 -0
  68. package/src/components/FileTree.astro +207 -0
  69. package/src/components/FileTree.meta.ts +34 -0
  70. package/src/components/FilterGrid.astro +170 -0
  71. package/src/components/FilterGrid.meta.ts +38 -0
  72. package/src/components/FooterReveal.astro +54 -0
  73. package/src/components/FooterReveal.meta.ts +31 -0
  74. package/src/components/GlitchText.astro +145 -0
  75. package/src/components/GlitchText.meta.ts +39 -0
  76. package/src/components/Globe.astro +304 -0
  77. package/src/components/Globe.meta.ts +41 -0
  78. package/src/components/GlyphField.astro +363 -0
  79. package/src/components/GlyphField.meta.ts +53 -0
  80. package/src/components/GradientText.astro +63 -0
  81. package/src/components/GradientText.meta.ts +35 -0
  82. package/src/components/GridPattern.astro +55 -0
  83. package/src/components/GridPattern.meta.ts +32 -0
  84. package/src/components/HighlightMarker.astro +68 -0
  85. package/src/components/HighlightMarker.meta.ts +37 -0
  86. package/src/components/HorizontalScroll.astro +112 -0
  87. package/src/components/HorizontalScroll.meta.ts +34 -0
  88. package/src/components/HoverImageLinks.astro +219 -0
  89. package/src/components/HoverImageLinks.meta.ts +34 -0
  90. package/src/components/HoverVideo.astro +179 -0
  91. package/src/components/HoverVideo.meta.ts +37 -0
  92. package/src/components/ImageCompare.astro +212 -0
  93. package/src/components/ImageCompare.meta.ts +39 -0
  94. package/src/components/ImageTrail.astro +169 -0
  95. package/src/components/ImageTrail.meta.ts +36 -0
  96. package/src/components/Lightbox.astro +236 -0
  97. package/src/components/Lightbox.meta.ts +40 -0
  98. package/src/components/LinkPreview.astro +234 -0
  99. package/src/components/LinkPreview.meta.ts +39 -0
  100. package/src/components/LiquidGlass.astro +148 -0
  101. package/src/components/LiquidGlass.meta.ts +37 -0
  102. package/src/components/Magnetic.astro +92 -0
  103. package/src/components/Magnetic.meta.ts +31 -0
  104. package/src/components/Marquee.astro +165 -0
  105. package/src/components/Marquee.meta.ts +38 -0
  106. package/src/components/Meteors.astro +156 -0
  107. package/src/components/Meteors.meta.ts +36 -0
  108. package/src/components/Noise.astro +61 -0
  109. package/src/components/Noise.meta.ts +33 -0
  110. package/src/components/NumberFlow.astro +143 -0
  111. package/src/components/NumberFlow.meta.ts +42 -0
  112. package/src/components/Orb.astro +130 -0
  113. package/src/components/Orb.meta.ts +39 -0
  114. package/src/components/OrbitingCircles.astro +119 -0
  115. package/src/components/OrbitingCircles.meta.ts +36 -0
  116. package/src/components/OtpInput.astro +215 -0
  117. package/src/components/OtpInput.meta.ts +42 -0
  118. package/src/components/PageTransition.astro +121 -0
  119. package/src/components/PageTransition.meta.ts +33 -0
  120. package/src/components/Parallax.astro +69 -0
  121. package/src/components/Parallax.meta.ts +32 -0
  122. package/src/components/Particles.astro +210 -0
  123. package/src/components/Particles.meta.ts +37 -0
  124. package/src/components/PersistPlayer.astro +126 -0
  125. package/src/components/PersistPlayer.meta.ts +35 -0
  126. package/src/components/PhoneFrame.astro +161 -0
  127. package/src/components/PhoneFrame.meta.ts +35 -0
  128. package/src/components/PillNav.astro +170 -0
  129. package/src/components/PillNav.meta.ts +37 -0
  130. package/src/components/Popover.astro +95 -0
  131. package/src/components/Popover.meta.ts +34 -0
  132. package/src/components/PriceSwitch.astro +251 -0
  133. package/src/components/PriceSwitch.meta.ts +41 -0
  134. package/src/components/Probe.astro +84 -0
  135. package/src/components/Progress.astro +165 -0
  136. package/src/components/Progress.meta.ts +40 -0
  137. package/src/components/ProgressiveBlur.astro +95 -0
  138. package/src/components/ProgressiveBlur.meta.ts +36 -0
  139. package/src/components/PromptInput.astro +273 -0
  140. package/src/components/PromptInput.meta.ts +46 -0
  141. package/src/components/ReadingToc.astro +153 -0
  142. package/src/components/ReadingToc.meta.ts +34 -0
  143. package/src/components/RetroGrid.astro +84 -0
  144. package/src/components/RetroGrid.meta.ts +36 -0
  145. package/src/components/Reveal.astro +292 -0
  146. package/src/components/Reveal.meta.ts +48 -0
  147. package/src/components/Ripple.astro +94 -0
  148. package/src/components/Ripple.meta.ts +34 -0
  149. package/src/components/RotatingText.astro +170 -0
  150. package/src/components/RotatingText.meta.ts +35 -0
  151. package/src/components/ScrollExpand.astro +90 -0
  152. package/src/components/ScrollExpand.meta.ts +35 -0
  153. package/src/components/ScrollProgress.astro +73 -0
  154. package/src/components/ScrollProgress.meta.ts +32 -0
  155. package/src/components/ScrollScrub.astro +96 -0
  156. package/src/components/ScrollScrub.meta.ts +37 -0
  157. package/src/components/ShimmerButton.astro +109 -0
  158. package/src/components/ShimmerButton.meta.ts +37 -0
  159. package/src/components/ShinyText.astro +72 -0
  160. package/src/components/ShinyText.meta.ts +35 -0
  161. package/src/components/Skeleton.astro +76 -0
  162. package/src/components/Skeleton.meta.ts +32 -0
  163. package/src/components/Sparkline.astro +168 -0
  164. package/src/components/Sparkline.meta.ts +43 -0
  165. package/src/components/Spinner.astro +79 -0
  166. package/src/components/Spinner.meta.ts +33 -0
  167. package/src/components/SplitFlap.astro +157 -0
  168. package/src/components/SplitFlap.meta.ts +39 -0
  169. package/src/components/SplitText.astro +208 -0
  170. package/src/components/SplitText.meta.ts +50 -0
  171. package/src/components/Spotlight.astro +92 -0
  172. package/src/components/Spotlight.meta.ts +34 -0
  173. package/src/components/StaggerMenu.astro +301 -0
  174. package/src/components/StaggerMenu.meta.ts +39 -0
  175. package/src/components/StatefulButton.astro +148 -0
  176. package/src/components/StatefulButton.meta.ts +35 -0
  177. package/src/components/StickyBanner.astro +168 -0
  178. package/src/components/StickyBanner.meta.ts +45 -0
  179. package/src/components/StickyReveal.astro +256 -0
  180. package/src/components/StickyReveal.meta.ts +33 -0
  181. package/src/components/StrokeDraw.astro +110 -0
  182. package/src/components/StrokeDraw.meta.ts +43 -0
  183. package/src/components/Switch.astro +165 -0
  184. package/src/components/Switch.meta.ts +37 -0
  185. package/src/components/Tabs.astro +194 -0
  186. package/src/components/Tabs.meta.ts +47 -0
  187. package/src/components/TextRoll.astro +78 -0
  188. package/src/components/TextRoll.meta.ts +36 -0
  189. package/src/components/TextScramble.astro +107 -0
  190. package/src/components/TextScramble.meta.ts +35 -0
  191. package/src/components/ThemeScript.astro +17 -0
  192. package/src/components/ThemeToggle.astro +182 -0
  193. package/src/components/ThemeToggle.meta.ts +34 -0
  194. package/src/components/Tilt.astro +97 -0
  195. package/src/components/Tilt.meta.ts +34 -0
  196. package/src/components/Toast.astro +184 -0
  197. package/src/components/Toast.meta.ts +33 -0
  198. package/src/components/TracingBeam.astro +113 -0
  199. package/src/components/TracingBeam.meta.ts +34 -0
  200. package/src/components/Typewriter.astro +124 -0
  201. package/src/components/Typewriter.meta.ts +39 -0
  202. package/src/components/UnderlineHover.astro +61 -0
  203. package/src/components/UnderlineHover.meta.ts +34 -0
  204. package/src/components/VanishInput.astro +240 -0
  205. package/src/components/VanishInput.meta.ts +41 -0
  206. package/src/components/VideoDialog.astro +230 -0
  207. package/src/components/VideoDialog.meta.ts +40 -0
  208. package/src/components/WeightWave.astro +70 -0
  209. package/src/components/WeightWave.meta.ts +36 -0
  210. package/src/components/internal/DialogMorph.astro +97 -0
  211. package/src/components/internal/PromptInputSubmitOnEnter.astro +32 -0
  212. package/src/index.d.ts +43 -0
  213. package/src/index.js +60 -0
  214. package/src/lib/canvas.ts +124 -0
  215. package/src/lib/confetti.ts +127 -0
  216. package/src/lib/contrast.ts +116 -0
  217. package/src/lib/form.ts +231 -0
  218. package/src/lib/geo.ts +77 -0
  219. package/src/lib/gsap.ts +58 -0
  220. package/src/lib/land.ts +11 -0
  221. package/src/lib/lenis.ts +112 -0
  222. package/src/lib/meta.ts +79 -0
  223. package/src/lib/morph.ts +74 -0
  224. package/src/lib/reduced-motion-plugin.js +299 -0
  225. package/src/lib/runtime.ts +215 -0
  226. package/src/lib/support.ts +102 -0
  227. package/src/lib/theme.ts +18 -0
  228. package/src/lib/toast.ts +17 -0
  229. package/src/styles/base.css +218 -0
  230. package/src/styles/motion.css +43 -0
  231. package/src/styles/theme.css +397 -0
  232. package/src/styles/theme.tailwind.css +75 -0
@@ -0,0 +1,156 @@
1
+ ---
2
+ /**
3
+ * Meteors — streaks falling diagonally across a twinkling sky. Each meteor
4
+ * is one element: a line with a bright head, rotated to `angle`, that
5
+ * travels along its own axis and fades; its start, delay, length and pace
6
+ * come from a seeded generator on the server, so the same seed always
7
+ * draws the same sky (the visual test relies on it) and nothing is random
8
+ * at runtime. The stars are two tiled gradient layers that blink in
9
+ * alternation by opacity. Travel is measured in the box's own size
10
+ * (container units), so it crosses any parent. Zero script.
11
+ */
12
+ import type { HTMLAttributes } from 'astro/types';
13
+
14
+ interface Props extends HTMLAttributes<'div'> {
15
+ /** Meteors in flight. */
16
+ count?: number;
17
+ /** Direction of travel in CSS degrees: 0 right, 90 down, 135 down-left, 45 down-right. */
18
+ angle?: number;
19
+ /** Draw the star field. */
20
+ stars?: boolean;
21
+ /** Seconds per crossing (each meteor varies ±40 %). */
22
+ duration?: number;
23
+ /** Meteor colour. */
24
+ color?: string;
25
+ /** Seed for the generator; the same seed is the same sky. */
26
+ seed?: number;
27
+ }
28
+
29
+ const { count = 12, angle = 135, stars = true, duration = 4, color = 'var(--ma-ink)', seed = 3, class: className, style, ...rest } = Astro.props;
30
+ const n = Math.max(1, Math.min(40, Math.round(count)));
31
+ // mulberry32, the generator GlyphField uses
32
+ function mulberry32(a: number) {
33
+ return () => {
34
+ a |= 0;
35
+ a = (a + 0x6d2b79f5) | 0;
36
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
37
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
38
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
39
+ };
40
+ }
41
+ const rand = mulberry32(Math.floor(seed) || 1);
42
+ const r2 = (v: number) => Math.round(v * 100) / 100;
43
+ const meteors = Array.from({ length: n }, () => ({
44
+ x: r2(rand() * 130 - 15),
45
+ y: r2(rand() * 80 - 40),
46
+ delay: r2(rand()),
47
+ pace: r2(0.6 + rand() * 0.8),
48
+ len: r2(0.5 + rand()),
49
+ }));
50
+ const inline = [`--ma-meteor-angle:${angle}deg`, `--ma-meteor-dur:${duration}s`, `--ma-meteor-color:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
51
+ ---
52
+
53
+ <div class:list={['ma-meteors', className]} aria-hidden="true" data-stars={stars ? '' : undefined} style={inline} {...rest}>
54
+ {meteors.map((m) => <i style={`--ma-meteor-x:${m.x}%;--ma-meteor-y:${m.y}%;--ma-meteor-d:${m.delay};--ma-meteor-p:${m.pace};--ma-meteor-l:${m.len}`}></i>)}
55
+ </div>
56
+
57
+ <style is:global>
58
+ @layer components {
59
+ :where(.ma-meteors) {
60
+ position: absolute;
61
+ inset: 0;
62
+ overflow: hidden;
63
+ pointer-events: none;
64
+ contain: strict;
65
+ container-type: size;
66
+ color: var(--ma-meteor-color, var(--ma-ink));
67
+ }
68
+ /* the sky: two layers of tiled star gradients that blink in alternation */
69
+ :where(.ma-meteors[data-stars])::before,
70
+ :where(.ma-meteors[data-stars])::after {
71
+ content: '';
72
+ position: absolute;
73
+ inset: 0;
74
+ background-image:
75
+ radial-gradient(circle, currentColor 0.6px, transparent 1.1px),
76
+ radial-gradient(circle, currentColor 0.5px, transparent 1px);
77
+ background-size:
78
+ 173px 131px,
79
+ 97px 211px;
80
+ background-position:
81
+ 23px 41px,
82
+ 61px 7px;
83
+ opacity: 0.45;
84
+ animation: ma-meteors-twinkle calc(var(--ma-meteor-dur) * 1.5) var(--ma-ease-in-out) infinite alternate;
85
+ }
86
+ :where(.ma-meteors[data-stars])::after {
87
+ background-size:
88
+ 149px 167px,
89
+ 113px 89px;
90
+ background-position:
91
+ 7px 83px,
92
+ 91px 29px;
93
+ animation-delay: calc(var(--ma-meteor-dur) * -0.75);
94
+ }
95
+ /* a meteor: a fading line with a bright head at its front, laid along its direction (local x) and sent along its own axis */
96
+ :where(.ma-meteors > i) {
97
+ position: absolute;
98
+ left: var(--ma-meteor-x, 50%);
99
+ top: var(--ma-meteor-y, 0%);
100
+ width: calc(var(--ma-meteor-l, 1) * 20cqmax);
101
+ height: 1px;
102
+ transform-origin: 100% 50%;
103
+ background: linear-gradient(to right, transparent, currentColor);
104
+ opacity: 0;
105
+ will-change: transform, opacity;
106
+ animation: ma-meteor calc(var(--ma-meteor-dur) * var(--ma-meteor-p, 1)) linear infinite;
107
+ animation-delay: calc(var(--ma-meteor-dur) * var(--ma-meteor-d, 0) * -2);
108
+ }
109
+ :where(.ma-meteors > i)::before {
110
+ content: '';
111
+ position: absolute;
112
+ left: 100%;
113
+ top: 50%;
114
+ width: 3px;
115
+ height: 3px;
116
+ translate: -50% -50%;
117
+ border-radius: 50%;
118
+ background: currentColor;
119
+ box-shadow: 0 0 6px 1px currentColor;
120
+ }
121
+ @media (prefers-reduced-motion: reduce) {
122
+ :where(.ma-meteors[data-stars])::before,
123
+ :where(.ma-meteors[data-stars])::after {
124
+ animation: none;
125
+ }
126
+ :where(.ma-meteors > i) {
127
+ display: none;
128
+ }
129
+ }
130
+ }
131
+
132
+ @keyframes ma-meteor {
133
+ 0% {
134
+ transform: rotate(var(--ma-meteor-angle, 135deg)) translateX(0);
135
+ opacity: 0;
136
+ }
137
+ 8% {
138
+ opacity: 1;
139
+ }
140
+ 70% {
141
+ opacity: 1;
142
+ }
143
+ 100% {
144
+ transform: rotate(var(--ma-meteor-angle, 135deg)) translateX(140cqmax);
145
+ opacity: 0;
146
+ }
147
+ }
148
+ @keyframes ma-meteors-twinkle {
149
+ from {
150
+ opacity: 0.2;
151
+ }
152
+ to {
153
+ opacity: 0.6;
154
+ }
155
+ }
156
+ </style>
@@ -0,0 +1,36 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'Meteors',
5
+ slug: 'meteors',
6
+ title: 'Meteors',
7
+ description: 'Streaks falling diagonally across a twinkling sky. Each meteor is one element with a bright head, rotated to its angle and sent along its own axis; start, delay, length and pace come from a seeded generator on the server, so the same seed always draws the same sky. Stars are two tiled gradient layers blinking in alternation. Zero JS; fills its positioned parent.',
8
+ summary: 'Meteors streaking diagonally across a twinkling star field, drawn the same way every time from a seed.',
9
+ category: 'background',
10
+ tier: 'A',
11
+ trigger: ['always'],
12
+ support: { baseline: 'widely', needs: ['container-units'] },
13
+ readout: '<Meteors count={12} angle={135}>',
14
+ usage: `<section class="relative overflow-hidden bg-black text-white">\n <Meteors count={16} />\n <h1 class="relative">Ship tonight</h1>\n</section>`,
15
+ props: [
16
+ { name: 'count', type: 'number', default: '12', description: 'Meteors in flight, up to 40.' },
17
+ { name: 'angle', type: 'number', default: '135', description: 'Direction of travel in CSS degrees: 0 right, 90 down, 135 down-left, 45 down-right.' },
18
+ { name: 'stars', type: 'boolean', default: 'true', description: 'Draw the star field.' },
19
+ { name: 'duration', type: 'number', default: '4', description: 'Seconds per crossing; each meteor varies ±40 % around it.' },
20
+ { name: 'color', type: 'string', default: 'var(--ma-ink)', description: 'Meteor and star colour.' },
21
+ { name: 'seed', type: 'number', default: '3', description: 'Seed for the generator; the same seed is the same sky.' },
22
+ ],
23
+ reducedMotion: 'The stars stand still; the meteors are not shown.',
24
+ clientRouter: 'CSS-only; nothing to rebind.',
25
+ craft: [
26
+ 'Everything random is decided on the server with a seeded generator (mulberry32, the one GlyphField uses): the HTML is stable across builds, the visual test is reproducible, and the browser computes nothing.',
27
+ 'A meteor travels along its own rotated axis (rotate(angle) translateX(d) in one transform keyframe), so one property moves it and the head stays in front.',
28
+ 'Travel is measured in container units (cqmax): the meteor crosses the box whatever its size, with no px distance in the stylesheet.',
29
+ 'The stars are two layers of tiled radial gradients at coprime tile sizes so the pattern never visibly repeats; they blink in alternation by opacity, on the in-out curve, half a period apart.',
30
+ 'Linear timing on the streaks: a meteor does not ease.',
31
+ ],
32
+ related: ['particles', 'retro-grid', 'aurora'],
33
+ intents: ['meteor shower background', 'falling stars animation', 'shooting stars css', 'meteors effect hero', 'starry night background'],
34
+ replaces: ['Meteors (Magic UI)', 'Meteors (Aceternity)', 'MeteorShower (Eldora)'],
35
+ fixture: { props: { count: 6, seed: 3 }, expect: 'class="ma-meteors"' },
36
+ } satisfies ComponentMeta;
@@ -0,0 +1,61 @@
1
+ ---
2
+ /**
3
+ * Noise — film grain over whatever is beneath. Zero JS: one SVG turbulence
4
+ * tile as a background, blended, with its translate stepped at 12 fps so
5
+ * the grain lives without repainting. Fills its positioned parent and clips
6
+ * the moving grain to it, so it never widens the page.
7
+ */
8
+ import type { HTMLAttributes } from 'astro/types';
9
+
10
+ interface Props extends HTMLAttributes<'div'> {
11
+ /** Strength, 0–1. */
12
+ opacity?: number;
13
+ /** Flicker the grain. */
14
+ animate?: boolean;
15
+ /** Blend mode against the layers beneath. */
16
+ blend?: 'overlay' | 'soft-light' | 'multiply' | 'screen' | 'normal';
17
+ /** Grain size: tile size in px. Smaller is finer. */
18
+ size?: number;
19
+ }
20
+
21
+ const { opacity = 0.08, animate = true, blend = 'overlay', size = 200, class: className, style, ...rest } = Astro.props;
22
+ const vars = [`--ma-noise-o:${opacity}`, `--ma-noise-blend:${blend}`, `--ma-noise-size:${size}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
23
+ ---
24
+
25
+ <div class:list={['ma-noise', className]} aria-hidden="true" data-animate={animate ? '' : undefined} style={vars} {...rest}></div>
26
+
27
+ <style is:global>
28
+ @layer components {
29
+ /* the box is the parent's size and clips; the grain is a pseudo-element 10% larger on every side, so its translate
30
+ never shows an edge. The oversized box itself used to be the grain, and it widened any page it touched the edge of */
31
+ :where(.ma-noise) {
32
+ position: absolute;
33
+ inset: 0;
34
+ overflow: hidden;
35
+ pointer-events: none;
36
+ mix-blend-mode: var(--ma-noise-blend, overlay);
37
+ opacity: var(--ma-noise-o, 0.08);
38
+ }
39
+ :where(.ma-noise)::before {
40
+ content: '';
41
+ position: absolute;
42
+ inset: -10%;
43
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
44
+ background-size: var(--ma-noise-size, 200px) var(--ma-noise-size, 200px);
45
+ }
46
+ :where(.ma-noise[data-animate])::before {
47
+ animation: ma-noise 0.5s steps(6) infinite;
48
+ }
49
+ @media (prefers-reduced-motion: reduce) {
50
+ :where(.ma-noise)::before {
51
+ animation: none;
52
+ }
53
+ }
54
+ }
55
+
56
+ @keyframes ma-noise {
57
+ to {
58
+ translate: 5% 3%;
59
+ }
60
+ }
61
+ </style>
@@ -0,0 +1,33 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'Noise',
5
+ slug: 'noise',
6
+ title: 'Noise',
7
+ description: 'Film grain over a gradient or image, blended and flickering at 12 fps. One SVG turbulence tile as a background whose translate steps; pure CSS, no canvas. Fills its positioned parent.',
8
+ summary: 'A film grain overlay that flickers at 12 fps over a gradient or an image.',
9
+ category: 'background',
10
+ tier: 'A',
11
+ trigger: ['always'],
12
+ support: { baseline: 'widely', needs: [] },
13
+ readout: '<Noise opacity={0.08}>',
14
+ usage: `<section class="relative">\n <Aurora />\n <Noise opacity={0.1} />\n <h2 class="relative">Grain over aurora</h2>\n</section>`,
15
+ props: [
16
+ { name: 'opacity', type: 'number', default: '0.08', description: 'Strength, 0–1. 0.04–0.12 reads as texture; more reads as static.' },
17
+ { name: 'animate', type: 'boolean', default: 'true', description: 'Flicker the grain by stepping its position at 12 fps.' },
18
+ { name: 'blend', type: "'overlay' | 'soft-light' | 'multiply' | 'screen' | 'normal'", default: "'overlay'", description: 'Blend mode against the layers beneath.' },
19
+ { name: 'size', type: 'number', default: '200', description: 'Tile size in px; smaller is finer grain.' },
20
+ ],
21
+ reducedMotion: 'The grain holds still.',
22
+ clientRouter: 'CSS-only; nothing to rebind.',
23
+ craft: [
24
+ 'steps(6) over 500 ms: twelve grain frames a second, the cadence of film, and every frame is a translate, with no feTurbulence re-render.',
25
+ 'The tile is stitched, so the seam never shows at any translate.',
26
+ 'The grain is a layer 10% larger than the box on every side, clipped by the box: the translate never shows an edge, and the grain never reaches past the parent to widen the page.',
27
+ 'overlay blend adds texture without shifting the colours beneath; soft-light is gentler, multiply darkens.',
28
+ ],
29
+ related: ['aurora', 'grid-pattern', 'dot-pattern'],
30
+ intents: ['noise overlay', 'film grain effect', 'grain texture css', 'noisy background'],
31
+ replaces: ['Noise (React Bits)', 'grainy gradient canvases'],
32
+ fixture: { expect: 'class="ma-noise"' },
33
+ } satisfies ComponentMeta;
@@ -0,0 +1,143 @@
1
+ ---
2
+ /**
3
+ * NumberFlow — digits roll to a new value. Every digit is a column of 0–9
4
+ * translated to the right row; the value lives in one custom property
5
+ * (--ma-nf-value) and each column derives its own digit from it with round()
6
+ * and mod(), so writing that property from your code rolls every column to
7
+ * its new digit on the preset spring, and leading columns collapse when the
8
+ * number gets shorter. No script of its own. The server also renders the
9
+ * value as text, which is what browsers without CSS math show.
10
+ */
11
+ import type { HTMLAttributes, HTMLTag } from 'astro/types';
12
+
13
+ interface Props extends HTMLAttributes<'span'> {
14
+ /** Initial value, ≥ 0. Later values come from `--ma-nf-value` on the element or any ancestor. */
15
+ value: number;
16
+ /** Element to render. */
17
+ as?: HTMLTag;
18
+ /** Integer columns to keep room for; leading zeros collapse, so this is headroom, not padding. */
19
+ digits?: number;
20
+ /** Decimal places, 0–4. */
21
+ decimals?: number;
22
+ /** Thousands separator. */
23
+ separator?: string;
24
+ /** Decimal mark. */
25
+ decimal?: string;
26
+ /** Text before the number. */
27
+ prefix?: string;
28
+ /** Text after the number. */
29
+ suffix?: string;
30
+ /** Roll duration in ms. Unset: the preset's --ma-duration. */
31
+ duration?: number;
32
+ }
33
+
34
+ const { value, as: Tag = 'span', digits = 1, decimals = 0, separator = ',', decimal = '.', prefix = '', suffix = '', duration, class: className, style, ...rest } = Astro.props;
35
+ const places = Math.max(0, Math.min(decimals, 4));
36
+ const abs = Math.max(0, value);
37
+ const fixed = abs.toFixed(places);
38
+ const [intPart, fracPart] = fixed.split('.');
39
+ const intCols = Math.max(1, digits, intPart!.length);
40
+ const label = `${prefix}${intPart!.replace(/\B(?=(\d{3})+(?!\d))/g, separator)}${fracPart ? decimal + fracPart : ''}${suffix}`;
41
+ // column k shows the digit at 10^k of the value scaled to an integer (value × 10^decimals); highest first
42
+ const cols = Array.from({ length: intCols + places }, (_, i) => intCols + places - 1 - i);
43
+ const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
44
+ const vars = [`--ma-nf-init:${abs}`, `--ma-nf-scale:${10 ** places}`, duration !== undefined && `--ma-nf-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
45
+ ---
46
+
47
+ <Tag class:list={['ma-nf', className]} data-value={abs} style={vars} {...rest}>
48
+ <span class="ma-nf__static">{label}</span>
49
+ <span class="ma-nf__digits" aria-hidden="true">
50
+ {prefix}
51
+ {cols.map((k) => {
52
+ const p = k - places;
53
+ return (
54
+ <>
55
+ {p === -1 && <span class="ma-nf__mark">{decimal}</span>}
56
+ <span class="ma-nf__col" data-lead={p > 0 ? '' : undefined} style={`--ma-nf-k:${10 ** k}`}>
57
+ <b class="ma-nf__strip">{DIGITS.map((d) => <i>{d}</i>)}</b>
58
+ </span>
59
+ {p > 0 && p % 3 === 0 && <span class="ma-nf__mark" data-lead style={`--ma-nf-k:${10 ** k}`}>{separator}</span>}
60
+ </>
61
+ );
62
+ })}
63
+ {suffix}
64
+ </span>
65
+ </Tag>
66
+
67
+ <style is:global>
68
+ @layer components {
69
+ :where(.ma-nf) {
70
+ display: inline-block;
71
+ font-variant-numeric: tabular-nums;
72
+ /* the live value: --ma-nf-value from your code or an ancestor, else the server's */
73
+ --ma-nf-v: var(--ma-nf-value, var(--ma-nf-init, 0));
74
+ --ma-nf-int: round(nearest, var(--ma-nf-v) * var(--ma-nf-scale, 1), 1);
75
+ }
76
+ /* the columns are presentation: not selectable, so a copy takes the value text instead of ten digits per column;
77
+ and a number reads left to right on a right-to-left page too, where the flex row put its columns back to front */
78
+ :where(.ma-nf__digits) {
79
+ display: none;
80
+ direction: ltr;
81
+ -webkit-user-select: none;
82
+ user-select: none;
83
+ }
84
+ /* the columns need round() and mod(); elsewhere the static text stays */
85
+ @supports (translate: calc(mod(round(down, 7, 1), 10) * 1em)) {
86
+ /* still the text a screen reader reads (and a copy copies): hidden from the eye only */
87
+ :where(.ma-nf__static) {
88
+ position: absolute;
89
+ width: 1px;
90
+ height: 1px;
91
+ overflow: hidden;
92
+ clip-path: inset(50%);
93
+ white-space: nowrap;
94
+ }
95
+ :where(.ma-nf__digits) {
96
+ display: inline-flex;
97
+ align-items: baseline;
98
+ }
99
+ }
100
+ :where(.ma-nf__col),
101
+ :where(.ma-nf__mark) {
102
+ --ma-nf-hi: round(down, var(--ma-nf-int) / var(--ma-nf-k, 1), 1);
103
+ }
104
+ :where(.ma-nf__col) {
105
+ display: inline-block;
106
+ height: 1em;
107
+ line-height: 1;
108
+ clip-path: inset(0);
109
+ --ma-nf-d: mod(var(--ma-nf-hi), 10);
110
+ }
111
+ :where(.ma-nf__strip) {
112
+ display: grid;
113
+ font-weight: inherit;
114
+ translate: 0 calc(var(--ma-nf-d) * -1em);
115
+ transition: translate var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease);
116
+ }
117
+ :where(.ma-nf__strip > i) {
118
+ display: block;
119
+ height: 1em;
120
+ line-height: 1;
121
+ font-style: normal;
122
+ }
123
+ /* leading columns (and the separator after them) collapse while the number is shorter */
124
+ :where(.ma-nf__col[data-lead]),
125
+ :where(.ma-nf__mark[data-lead]) {
126
+ max-width: calc(min(1, var(--ma-nf-hi)) * 2ch);
127
+ overflow: clip;
128
+ transition: max-width var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease);
129
+ }
130
+ :where(.ma-nf__col[data-lead]) {
131
+ transition:
132
+ max-width var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease),
133
+ translate var(--ma-nf-dur, var(--ma-duration)) var(--ma-ease);
134
+ }
135
+ @media (prefers-reduced-motion: reduce) {
136
+ :where(.ma-nf__strip),
137
+ :where(.ma-nf__col[data-lead]),
138
+ :where(.ma-nf__mark[data-lead]) {
139
+ transition: none;
140
+ }
141
+ }
142
+ }
143
+ </style>
@@ -0,0 +1,42 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'NumberFlow',
5
+ slug: 'number-flow',
6
+ title: 'Number Flow',
7
+ description:
8
+ 'A number whose digits roll to the new value when it changes. Each column slides to its digit on the preset spring, thousands separators stay put, and leading columns collapse when the number gets shorter. The value is one custom property your code writes; every column derives its digit from it with round() and mod(). No script of its own.',
9
+ summary: 'A number whose digits roll to the new value when it changes, keeping the thousands separators in place.',
10
+ category: 'data',
11
+ tier: 'A',
12
+ trigger: ['click'],
13
+ support: { baseline: 'newly', needs: ['css-math'], fallback: 'the value as plain text, updated only by a re-render' },
14
+ readout: '<NumberFlow value={1284}>',
15
+ usage: `<NumberFlow value={1284} id="stars" />\n<NumberFlow value={99.5} decimals={1} suffix="%" />\n\n<script>\n // roll to a new value: write the property, keep the text honest (a screen reader reads it; an older browser shows it)\n const n = document.getElementById('stars');\n n.style.setProperty('--ma-nf-value', '1321');\n n.querySelector('.ma-nf__static').textContent = '1,321';\n</script>`,
16
+ props: [
17
+ { name: 'value', type: 'number', description: 'Initial value, 0 or more. Later values are written to --ma-nf-value on the element (or an ancestor, which is how PriceSwitch drives it).' },
18
+ { name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
19
+ { name: 'digits', type: 'number', default: '1', description: 'Integer columns to keep room for; leading zeros collapse, so a number that grows past the initial width still fits.' },
20
+ { name: 'decimals', type: 'number', default: '0', description: 'Decimal places, 0–4.' },
21
+ { name: 'separator', type: 'string', default: "','", description: 'Thousands separator.' },
22
+ { name: 'decimal', type: 'string', default: "'.'", description: 'Decimal mark.' },
23
+ { name: 'prefix', type: 'string', description: 'Text before the number, e.g. $.' },
24
+ { name: 'suffix', type: 'string', description: 'Text after the number, e.g. %.' },
25
+ { name: 'duration', type: 'number', default: 'preset (ui: 450)', description: 'Roll duration in ms; unset, the active preset\'s.' },
26
+ ],
27
+ reducedMotion: 'Digits change in place; nothing rolls.',
28
+ clientRouter: 'CSS-only; nothing to rebind. A value written before a navigation is not carried over unless the element is persisted.',
29
+ craft: [
30
+ 'One custom property is the whole API. The column for 10^k shows mod(round(down, value / 10^k), 10): no script parses the number into digits, so the component can be driven by a radio and :has() as easily as by JavaScript.',
31
+ 'Columns roll on the preset spring; with lively, the strip overshoots by a sliver of the next digit and settles, which is the detail number-flow libraries spend a spring on.',
32
+ 'Leading columns collapse through max-width, and the separator after them collapses with them, so 1,284 rolling to 96 shrinks to two digits instead of showing 0,096.',
33
+ 'Tabular figures and a fixed 1 em row: the number never jitters sideways while rolling and every column is exactly one digit tall.',
34
+ 'The server renders the value as text as well; browsers without CSS math show that text and never a column of zeros. Everywhere else the same text is visually hidden, not removed: it is what a screen reader reads, so the columns stay aria-hidden and there is no aria-label on a span.',
35
+ 'The columns take no selection (user-select: none), so selecting and copying the number copies the visually hidden text and never ten digits per column. They stay left to right on a right-to-left page, where a number still reads that way.',
36
+ 'Rolls to the shorter path are not chosen: 9 → 0 rolls back through 8. Direction-aware rolling needs script, and the plain roll reads fine at one spring length.',
37
+ ],
38
+ related: ['count-up', 'price-switch', 'countdown-flip'],
39
+ intents: ['number flow', 'rolling digits', 'odometer number animation', 'animated counter on change', 'slot machine digits css', 'number transition'],
40
+ replaces: ['NumberFlow (number-flow, Motion Primitives)', 'AnimatedNumber (Skiper, motion.dev)'],
41
+ fixture: { props: { value: 1284 }, expect: 'ma-nf__static">1,284</span>' },
42
+ } satisfies ComponentMeta;
@@ -0,0 +1,130 @@
1
+ ---
2
+ /**
3
+ * Orb — a sphere of light that breathes and slowly turns its colour. Three
4
+ * radial gradients on one element, already soft at their edges so no blur
5
+ * filter is ever painted; the breath is a `scale` on the compositor and
6
+ * the colour shift is one registered <number> (0 → 1) scaled to a drift
7
+ * angle the gradients add to their hue — animate the number, every stop follows. A conic highlight turns
8
+ * around the rim. Centred in its positioned parent.
9
+ */
10
+ import type { HTMLAttributes } from 'astro/types';
11
+
12
+ interface Props extends HTMLAttributes<'div'> {
13
+ /** Diameter, any CSS length. */
14
+ size?: string;
15
+ /** Base hue in degrees (oklch). */
16
+ hue?: number;
17
+ /** Degrees between the three stops; negative runs the other way around the wheel. */
18
+ spread?: number;
19
+ /** Chroma 0–0.3; low reads as haze, high as neon. */
20
+ chroma?: number;
21
+ /** Three colours that replace the hue-derived stops (then the hue does not drift). */
22
+ colors?: [string, string, string];
23
+ /** Breath amplitude as a fraction of the size, 0 keeps it still. */
24
+ pulse?: number;
25
+ /** Seconds per breath. */
26
+ duration?: number;
27
+ /** Degrees the hue drifts over a cycle; 0 pins it. */
28
+ drift?: number;
29
+ /** Opacity, 0–1. */
30
+ opacity?: number;
31
+ }
32
+
33
+ const { size = '18rem', hue = 250, spread = 40, chroma = 0.16, colors, pulse = 0.06, duration = 6, drift = 60, opacity = 1, class: className, style, ...rest } = Astro.props;
34
+ const inline = [
35
+ `--ma-orb-size:${size}`,
36
+ `--ma-orb-hue:${hue}deg`,
37
+ `--ma-orb-spread:${spread}deg`,
38
+ `--ma-orb-chroma:${chroma}`,
39
+ `--ma-orb-pulse:${pulse}`,
40
+ `--ma-orb-dur:${duration}s`,
41
+ `--ma-orb-drift:${drift}deg`,
42
+ `--ma-orb-opacity:${opacity}`,
43
+ colors && `--ma-orb-c1:${colors[0]};--ma-orb-c2:${colors[1]};--ma-orb-c3:${colors[2]}`,
44
+ typeof style === 'string' ? style : '',
45
+ ]
46
+ .filter(Boolean)
47
+ .join(';');
48
+ ---
49
+
50
+ <div class:list={['ma-orb', className]} aria-hidden="true" data-fixed={colors ? '' : undefined} style={inline} {...rest}></div>
51
+
52
+ <style is:global>
53
+ /* the drift, 0 → 1, animated with literal keyframes: Firefox does not interpolate a registered property whose keyframe value is a var() (7C) */
54
+ @property --ma-orb-t {
55
+ syntax: '<number>';
56
+ inherits: false;
57
+ initial-value: 0;
58
+ }
59
+
60
+ @layer components {
61
+ :where(.ma-orb) {
62
+ position: absolute;
63
+ left: 50%;
64
+ top: 50%;
65
+ width: var(--ma-orb-size, 18rem);
66
+ height: var(--ma-orb-size, 18rem);
67
+ translate: -50% -50%;
68
+ border-radius: 50%;
69
+ pointer-events: none;
70
+ opacity: var(--ma-orb-opacity, 1);
71
+ /* the live hue offset: the drift scaled by the animated number */
72
+ --ma-orb-h: calc(var(--ma-orb-t) * var(--ma-orb-drift, 60deg));
73
+ --ma-orb-l: calc(72% - 14% * var(--ma-dark, 0));
74
+ --ma-orb-s1: var(--ma-orb-c1, oklch(var(--ma-orb-l) var(--ma-orb-chroma, 0.16) calc(var(--ma-orb-hue, 250deg) + var(--ma-orb-h))));
75
+ --ma-orb-s2: var(--ma-orb-c2, oklch(calc(var(--ma-orb-l) - 10%) var(--ma-orb-chroma, 0.16) calc(var(--ma-orb-hue, 250deg) + var(--ma-orb-spread, 40deg) + var(--ma-orb-h))));
76
+ --ma-orb-s3: var(--ma-orb-c3, oklch(calc(var(--ma-orb-l) - 22%) var(--ma-orb-chroma, 0.16) calc(var(--ma-orb-hue, 250deg) + var(--ma-orb-spread, 40deg) * 2 + var(--ma-orb-h))));
77
+ background:
78
+ radial-gradient(circle at 32% 30%, color-mix(in oklch, var(--ma-orb-s1) 90%, white) 0%, transparent 46%),
79
+ radial-gradient(circle at 70% 68%, var(--ma-orb-s3) 0%, transparent 55%),
80
+ radial-gradient(circle at 50% 50%, var(--ma-orb-s2) 0%, color-mix(in oklch, var(--ma-orb-s2) 50%, transparent) 55%, transparent 72%);
81
+ will-change: scale;
82
+ animation:
83
+ ma-orb-breathe var(--ma-orb-dur) var(--ma-ease-in-out) infinite alternate,
84
+ ma-orb-hue calc(var(--ma-orb-dur) * 4) var(--ma-ease-in-out) infinite alternate;
85
+ }
86
+ :where(.ma-orb[data-fixed]) {
87
+ animation-name: ma-orb-breathe;
88
+ }
89
+ /* the rim: a soft conic highlight turning around the sphere */
90
+ :where(.ma-orb)::after {
91
+ content: '';
92
+ position: absolute;
93
+ inset: 6%;
94
+ border-radius: inherit;
95
+ background: conic-gradient(from 0deg, transparent 0 55%, color-mix(in oklch, var(--ma-orb-s1) 55%, white) 72%, transparent 88%);
96
+ mask-image: radial-gradient(circle, transparent 60%, #000 74%, transparent 84%);
97
+ opacity: 0.7;
98
+ will-change: rotate;
99
+ animation: ma-orb-rim calc(var(--ma-orb-dur) * 3) linear infinite;
100
+ }
101
+ @media (prefers-reduced-motion: reduce) {
102
+ :where(.ma-orb),
103
+ :where(.ma-orb)::after {
104
+ animation: none;
105
+ }
106
+ }
107
+ }
108
+
109
+ @keyframes ma-orb-breathe {
110
+ from {
111
+ scale: 1;
112
+ }
113
+ to {
114
+ scale: calc(1 + var(--ma-orb-pulse, 0.06));
115
+ }
116
+ }
117
+ @keyframes ma-orb-hue {
118
+ from {
119
+ --ma-orb-t: 0;
120
+ }
121
+ to {
122
+ --ma-orb-t: 1;
123
+ }
124
+ }
125
+ @keyframes ma-orb-rim {
126
+ to {
127
+ rotate: 360deg;
128
+ }
129
+ }
130
+ </style>
@@ -0,0 +1,39 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'Orb',
5
+ slug: 'orb',
6
+ title: 'Orb',
7
+ description: 'A sphere of light that breathes and slowly turns its colour. Three radial gradients on one element, soft at their edges so no blur filter is ever painted; the breath is a scale on the compositor and the colour shift is one registered number scaled to a drift angle every gradient adds to its hue. A conic highlight turns around the rim. Zero JS; centred in its positioned parent.',
8
+ summary: 'A glowing orb that breathes and slowly shifts its colour, drawn with gradients and no blur filter.',
9
+ category: 'background',
10
+ tier: 'A',
11
+ trigger: ['always'],
12
+ support: { baseline: 'newly', needs: ['property'], fallback: 'the sphere breathes; the hue does not drift' },
13
+ readout: '<Orb hue={250} pulse={0.06}>',
14
+ usage: `<section class="relative grid min-h-[32rem] place-items-center">\n <Orb hue={250} size="24rem" />\n <form class="relative">…</form>\n</section>`,
15
+ props: [
16
+ { name: 'size', type: 'string', default: "'18rem'", description: 'Diameter, any CSS length.' },
17
+ { name: 'hue', type: 'number', default: '250', description: 'Base hue in degrees (oklch).' },
18
+ { name: 'spread', type: 'number', default: '40', description: 'Degrees between the three stops; the second and third sit one and two spreads along the wheel, the other way when negative.' },
19
+ { name: 'chroma', type: 'number', default: '0.16', description: 'Chroma 0–0.3; low reads as haze, high as neon.' },
20
+ { name: 'colors', type: '[string, string, string]', description: 'Three colours replacing the hue-derived stops; the hue then does not drift.' },
21
+ { name: 'pulse', type: 'number', default: '0.06', description: 'Breath amplitude as a fraction of the size; 0 keeps it still.' },
22
+ { name: 'duration', type: 'number', default: '6', description: 'Seconds per breath; the hue drifts over four breaths, the rim turns in three.' },
23
+ { name: 'drift', type: 'number', default: '60', description: 'Degrees the hue wanders over a cycle; 0 pins it.' },
24
+ { name: 'opacity', type: 'number', default: '1', description: 'Strength, 0–1.' },
25
+ ],
26
+ reducedMotion: 'A still sphere at its base hue.',
27
+ clientRouter: 'CSS-only; nothing to rebind.',
28
+ craft: [
29
+ 'No filter: blur() on a large element is a full-size paint every frame; gradients that are already soft cost nothing to move.',
30
+ 'One registered <number>, 0 → 1 with literal keyframes, is the whole colour animation: scaled by the drift and added to the hue of the three oklch stops, so the sphere turns through the wheel without a second layer and without hue-rotate(). Literal, because Firefox does not interpolate a registered property whose keyframe value is a var().',
31
+ 'Lightness follows the theme through the --ma-dark scalar: a lighter sphere on a light ground, a deeper one on dark.',
32
+ 'The rim is a conic highlight under a ring mask, rotating on the compositor. It is the one detail that makes a flat gradient read as a sphere.',
33
+ 'Breath runs on the in-out curve with alternate direction, so the sphere shrinks as well as grows.',
34
+ ],
35
+ related: ['aurora', 'particles', 'ripple'],
36
+ intents: ['glowing orb background', 'breathing orb', 'ai orb animation', 'gradient sphere hero', 'siri orb css'],
37
+ replaces: ['Orb (React Bits)', 'Orb (Kokonut UI)'],
38
+ fixture: { expect: 'class="ma-orb"' },
39
+ } satisfies ComponentMeta;