@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,39 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'SplitFlap',
5
+ slug: 'split-flap',
6
+ title: 'Split Flap',
7
+ description:
8
+ 'A departures board: every character spins through the alphabet and locks, one cell after another, when the board scrolls into view. Each glyph is a CSS counter on a registered integer that transitions from blank to the letter, and a flap turns once per step. Composes Reveal; no script of its own. The server HTML already reads the final text.',
9
+ summary: 'A split-flap departures board: each character spins through the alphabet and locks as the board scrolls into view.',
10
+ category: 'text',
11
+ tier: 'A',
12
+ trigger: ['scroll'],
13
+ support: { baseline: 'newly', needs: ['counter-set', 'property', 'counter-style'], fallback: 'the final text, shown at once' },
14
+ readout: '<SplitFlap text="DEPARTURES">',
15
+ usage: `<SplitFlap as="h2" text="Departures" class="font-mono" />\n<SplitFlap text="AMS 14:05 ON TIME" step={45} stagger={30} />`,
16
+ props: [
17
+ { name: 'text', type: 'string', description: 'The text. Boards are uppercase, so letters are uppercased; A–Z, 0–9 and : . - / + & ? ! are in the set, anything else renders as ?.' },
18
+ { name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
19
+ { name: 'step', type: 'number', default: 'preset (ui: 60)', description: 'Milliseconds per character step (one flap). Unset, it follows the preset\'s --ma-stagger; 45–70 reads like a real board.' },
20
+ { name: 'stagger', type: 'number', default: 'preset (40)', description: 'Milliseconds between neighbouring cells starting. Unset, the preset\'s --ma-stagger-tight.' },
21
+ { name: 'delay', type: 'number', default: '0', description: 'Delay in ms before the first cell.' },
22
+ { name: 'once', type: 'boolean', default: 'true', description: 'Play once and stay; false replays on every entry.' },
23
+ { name: 'threshold', type: 'number', default: '0.2', description: 'Fraction visible to trigger.' },
24
+ ],
25
+ reducedMotion: 'The final text appears at once; nothing spins.',
26
+ clientRouter: 'Inherits Reveal: rebinds after every navigation, so the board replays on a fresh page.',
27
+ craft: [
28
+ 'The letter is content: counter() on a registered <integer> with a fixed-system @counter-style whose symbols are the charset. Integers interpolate in steps, so a linear transition from 0 to 19 rewrites the glyph nineteen times. The spin is the browser stepping through the alphabet, not a sprite.',
29
+ 'Each cell\'s transition lasts index × step, so A locks first and Z last: cells lock one by one in the order a real board does, and the flap animation runs exactly index times at the same step, so the turn and the letter change stay in sync.',
30
+ 'One flap: the top half turning down over the hinge on an ease-in is what the eye reads; the bottom half stays put. Perspective on the cell keeps the turn from looking like a squash.',
31
+ 'Blank at index 0, so every cell starts empty and the board fills in. The word is legible at the moment the last cell locks, never before.',
32
+ 'A visually hidden span (.ma-sr) carries the string; the cells are aria-hidden and the counters are decoration.',
33
+ 'One cell per grapheme, and the board takes dir="auto" from its own text: the cells are a flex row, which follows the page\'s direction, and on a right-to-left page it spelled the word backwards.',
34
+ ],
35
+ related: ['count-up', 'text-scramble', 'typewriter'],
36
+ intents: ['split flap text', 'departures board animation', 'airport board text', 'flip board letters', 'solari board css'],
37
+ replaces: ['SplitFlap / FlipText effects (React Bits, Aceternity)', 'split-flap canvas boards'],
38
+ fixture: { props: { text: 'Fixture content for SplitFlap' }, expect: 'ma-sr">Fixture content for SplitFlap</span>' },
39
+ } satisfies ComponentMeta;
@@ -0,0 +1,208 @@
1
+ ---
2
+ /**
3
+ * SplitText — split on the server, reveal on scroll, one string to a reader.
4
+ *
5
+ * Composes Reveal: the parts are direct children with a per-child stagger, so
6
+ * it inherits once-and-stay, reduced motion and ClientRouter safety, and ships
7
+ * no script of its own. A visually hidden copy (.ma-sr) + aria-hidden parts keep it
8
+ * one string for assistive tech instead of a stream of letters.
9
+ *
10
+ * `mask` wraps every part in a clipped box it rises out of, so the text is
11
+ * uncovered from its own baseline instead of fading up. `scrub` binds each
12
+ * word's opacity to the block's view() timeline — words light up as you read
13
+ * down the page — and is simply full where scroll timelines do not exist.
14
+ */
15
+ import type { HTMLAttributes, HTMLTag } from 'astro/types';
16
+ import Reveal from './Reveal.astro';
17
+
18
+ interface Props extends HTMLAttributes<'span'> {
19
+ /** The text to split. Use `\n` for explicit lines in `by="line"`. */
20
+ text: string;
21
+ /** Element to render. */
22
+ as?: HTMLTag;
23
+ /** Split unit. */
24
+ by?: 'word' | 'char' | 'line';
25
+ /** Stagger between parts in ms. Defaults: word 40, char 25, line 90. */
26
+ step?: number;
27
+ /** Cap on the total stagger in ms; the step shrinks to fit long text. */
28
+ maxTotal?: number;
29
+ /** Per-part duration in ms. */
30
+ duration?: number;
31
+ /** Where parts travel from. */
32
+ from?: 'bottom' | 'top' | 'none';
33
+ /** Travel distance in px. */
34
+ distance?: number;
35
+ /** Blur in px to sharpen from. */
36
+ blur?: number;
37
+ /** Delay in ms before the first part. */
38
+ delay?: number;
39
+ /** Any CSS easing. */
40
+ easing?: string;
41
+ /** Play once and stay. */
42
+ once?: boolean;
43
+ /** Fraction visible to trigger. */
44
+ threshold?: number;
45
+ /** IntersectionObserver rootMargin. */
46
+ rootMargin?: string;
47
+ /** Each part rises from behind a clip of its own line instead of fading up. */
48
+ mask?: boolean;
49
+ /** Scroll-linked: words brighten from dim to full as the block moves through the viewport (animation-timeline: view()). Without scroll timelines every word is full. */
50
+ scrub?: boolean;
51
+ /** Resting opacity of an unread word in scrub mode, 0–1. */
52
+ dim?: number;
53
+ }
54
+
55
+ const {
56
+ text,
57
+ as = 'span',
58
+ by = 'word',
59
+ step,
60
+ maxTotal = 600,
61
+ duration = 500,
62
+ from = 'bottom',
63
+ distance = 12,
64
+ blur,
65
+ delay,
66
+ easing,
67
+ once,
68
+ threshold,
69
+ rootMargin,
70
+ mask = false,
71
+ scrub = false,
72
+ dim = 0.25,
73
+ class: className,
74
+ style,
75
+ ...rest
76
+ } = Astro.props;
77
+
78
+ // a character is a grapheme, not a code point: an emoji with a skin tone or a letter with a combining accent is one part
79
+ const parts: string[] = by === 'char' ? Array.from(new Intl.Segmenter().segment(text), (g) => g.segment) : by === 'line' ? text.split('\n') : text.split(/(\s+)/).filter(Boolean);
80
+ const animated = parts.filter((p) => !/^\s+$/.test(p)).length;
81
+ const defaultStep = by === 'char' ? 25 : by === 'line' ? 90 : 40;
82
+ // Cap the total: forty words at 40 ms is 1.6 s, which is a wait, not an entrance.
83
+ const interval = Math.max(1, Math.min(step ?? defaultStep, animated > 1 ? maxTotal / (animated - 1) : maxTotal));
84
+ // scrub: each word owns a slice of the block's cover range, 20% → 60%, with a little overlap
85
+ const scrubVars = scrub ? `--ma-split-n:${animated};--ma-split-k:${(40 / Math.max(animated, 1)).toFixed(3)}%;--ma-split-dim:${dim}` : '';
86
+ const inline = [scrubVars, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
87
+ let index = 0;
88
+ // dir="auto" for words and characters: the parts are inline-blocks, which bidi orders by the page's direction, so Latin
89
+ // text on a right-to-left page came out back to front. Lines are blocks of plain text and keep the page's direction.
90
+ ---
91
+
92
+ <Reveal
93
+ as={as}
94
+ stagger={scrub ? undefined : interval}
95
+ duration={duration}
96
+ from={mask || scrub ? 'none' : from}
97
+ distance={distance}
98
+ blur={mask || scrub ? undefined : blur}
99
+ delay={delay}
100
+ easing={easing}
101
+ once={once}
102
+ threshold={scrub ? 0 : threshold}
103
+ rootMargin={scrub ? '0px 0px 0px 0px' : rootMargin}
104
+ class={['ma-split', `ma-split--${by}`, mask && 'ma-split--mask', mask && from === 'top' && 'ma-split--top', scrub && 'ma-split--scrub', className].filter(Boolean).join(' ')}
105
+ dir={by === 'line' ? undefined : 'auto'}
106
+ style={inline || undefined}
107
+ {...rest}
108
+ >
109
+ {parts.map((part) =>
110
+ /^\s+$/.test(part) ? (
111
+ ' '
112
+ ) : mask ? (
113
+ <span class="ma-split__mask">
114
+ <span class="ma-split__part" aria-hidden="true">{part}</span>
115
+ </span>
116
+ ) : (
117
+ <span class="ma-split__part" aria-hidden="true" style={scrub ? `--ma-split-i:${index++}` : undefined}>{part}</span>
118
+ ),
119
+ )}
120
+ <span class="ma-sr">{text}</span>
121
+ </Reveal>
122
+
123
+ <style is:global>
124
+ @layer components {
125
+ /* break-spaces, not pre: both keep a part's text exactly as written, but pre also forbade wrapping, so a word
126
+ wider than its line ran out of it whatever overflow-wrap the host set; now overflow-wrap: anywhere breaks it */
127
+ :where(.ma-split__part) {
128
+ display: inline-block;
129
+ white-space: break-spaces;
130
+ }
131
+ /* a line is the author's line, not the screen's: one longer than the column wraps inside its own block instead of
132
+ running out of it (white-space: pre kept a two-word line from ever breaking on a phone) */
133
+ :where(.ma-split--line > .ma-split__part),
134
+ :where(.ma-split--line > .ma-split__mask),
135
+ :where(.ma-split--line > .ma-split__mask > .ma-split__part) {
136
+ display: block;
137
+ white-space: pre-wrap;
138
+ }
139
+ /* mask: the wrapper is Reveal's staggered child (it only fades, from="none"); the part rises inside its clip */
140
+ :where(.ma-split__mask) {
141
+ display: inline-block;
142
+ clip-path: inset(0 0 0 0);
143
+ white-space: break-spaces;
144
+ }
145
+ /* the rise keeps the timing of the wrapper's fade: Reveal's delay on the root and its index on the wrapper */
146
+ :where(.ma-split--mask .ma-split__part) {
147
+ translate: 0 0;
148
+ transition: translate var(--ma-r-dur, var(--ma-duration)) var(--ma-r-ease, var(--ma-ease)) calc(var(--ma-r-delay, 0ms) + var(--ma-r-i, 0) * var(--ma-r-stagger, var(--ma-stagger)));
149
+ }
150
+ :where([data-ma-js] .ma-split--mask:not(.ma-in) .ma-split__part) {
151
+ translate: 0 110%;
152
+ }
153
+ :where([data-ma-js] .ma-split--top:not(.ma-in) .ma-split__part) {
154
+ translate: 0 -110%;
155
+ }
156
+ :where(.ma-split--mask[data-leaving] .ma-split__part) {
157
+ transition-duration: var(--ma-duration-fast);
158
+ transition-delay: 0ms;
159
+ }
160
+ /* scrub: every word owns a slice of the block's own view timeline; nothing here runs where view() does not exist */
161
+ @supports (animation-timeline: view()) {
162
+ :where(.ma-split--scrub) {
163
+ view-timeline-name: --ma-split;
164
+ }
165
+ :where(.ma-split--scrub .ma-split__part) {
166
+ animation: ma-split-lit linear both;
167
+ }
168
+ /* separate rule on purpose — lightningcss would fold animation-timeline into the shorthand, which browsers reject */
169
+ :where(.ma-split--scrub) :where(.ma-split__part) {
170
+ animation-timeline: --ma-split;
171
+ animation-range: cover calc(20% + var(--ma-split-i, 0) * var(--ma-split-k, 10%)) cover calc(28% + (var(--ma-split-i, 0) + 1) * var(--ma-split-k, 10%));
172
+ }
173
+ }
174
+ @media (prefers-reduced-motion: reduce) {
175
+ /* Reveal already drops its travel; the masked parts drop theirs and the scrub reads full. */
176
+ :where([data-ma-js] .ma-split--mask:not(.ma-in) .ma-split__part) {
177
+ translate: 0 0;
178
+ }
179
+ :where(.ma-split--mask .ma-split__part) {
180
+ transition-duration: var(--ma-duration-fast);
181
+ transition-delay: 0ms;
182
+ }
183
+ :where(.ma-split--scrub .ma-split__part) {
184
+ animation: none;
185
+ }
186
+ }
187
+ /* Print: Reveal shows the parts; the masked ones also come out from under their clip, and the scrub reads full. A
188
+ masked line printed before it scrolled into view was blank. */
189
+ @media print {
190
+ :where(.ma-split--mask .ma-split__part) {
191
+ translate: none;
192
+ transition: none;
193
+ }
194
+ :where(.ma-split--scrub .ma-split__part) {
195
+ animation: none;
196
+ }
197
+ }
198
+ }
199
+
200
+ @keyframes ma-split-lit {
201
+ from {
202
+ opacity: var(--ma-split-dim, 0.25);
203
+ }
204
+ to {
205
+ opacity: 1;
206
+ }
207
+ }
208
+ </style>
@@ -0,0 +1,50 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'SplitText',
5
+ slug: 'split-text',
6
+ title: 'Split Text',
7
+ description:
8
+ 'Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view. One string to screen readers, zero script of its own.',
9
+ summary: 'Split text animation: words, characters or lines split on the server and revealed with a stagger on scroll.',
10
+ category: 'text',
11
+ tier: 'A',
12
+ trigger: ['scroll'],
13
+ support: { baseline: 'widely', needs: [], fallback: 'scrub uses animation-timeline: view() (Chrome 115, Safari 26); elsewhere every word is full' },
14
+ readout: '<SplitText step={45}>',
15
+ usage: `<SplitText as="h1" text="Split on the server, revealed on scroll." />\n<SplitText by="char" text="letter by letter" step={25} />\n<SplitText as="h2" text="Rises from behind its own line." mask />\n<SplitText as="p" text="Words light up as you read down the page…" scrub />`,
16
+ props: [
17
+ { name: 'text', type: 'string', description: 'The text to split. Use \\n for explicit lines with by="line".' },
18
+ { name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
19
+ { name: 'by', type: "'word' | 'char' | 'line'", default: "'word'", description: 'Split unit.' },
20
+ { name: 'step', type: 'number', description: 'Stagger between parts in ms. Defaults: word 40, char 25, line 90.' },
21
+ { name: 'maxTotal', type: 'number', default: '600', description: 'Cap on the total stagger in ms; the step shrinks to fit long text.' },
22
+ { name: 'duration', type: 'number', default: '500', description: 'Per-part duration in ms.' },
23
+ { name: 'from', type: "'bottom' | 'top' | 'none'", default: "'bottom'", description: 'Where parts travel from.' },
24
+ { name: 'distance', type: 'number', default: '12', description: 'Travel distance in px.' },
25
+ { name: 'blur', type: 'number', description: 'Blur in px to sharpen from.' },
26
+ { name: 'delay', type: 'number', default: '0', description: 'Delay before the first part, ms.' },
27
+ { name: 'easing', type: 'string', default: 'preset curve', description: 'Any CSS easing for each part. Unset, the preset\'s sampled spring.' },
28
+ { name: 'once', type: 'boolean', default: 'true', description: 'Play once and stay.' },
29
+ { name: 'threshold', type: 'number', default: '0.2', description: 'Fraction visible to trigger.' },
30
+ { name: 'rootMargin', type: 'string', default: "'0px 0px -10% 0px'", description: 'IntersectionObserver rootMargin, passed to Reveal; the default waits until the text is 10% into the viewport. scrub ignores it.' },
31
+ { name: 'mask', type: 'boolean', default: 'false', description: 'Each part is uncovered as it rises from behind a clip of its own line. from="top" drops it in instead.' },
32
+ { name: 'scrub', type: 'boolean', default: 'false', description: 'Scroll-linked: words brighten from dim to full, in order, as the block moves through the viewport, on its own view() timeline. Where scroll timelines do not exist every word is full.' },
33
+ { name: 'dim', type: 'number', default: '0.25', description: 'Resting opacity of an unread word in scrub mode.' },
34
+ ],
35
+ reducedMotion: 'Inherits Reveal: fast opacity fade, no travel, no stagger.',
36
+ clientRouter: 'Inherits Reveal: rebinds through the shared runtime after every navigation.',
37
+ craft: [
38
+ 'Split on the server so the HTML already contains every word; nothing is measured or re-flowed on the client.',
39
+ 'by="char" splits graphemes (Intl.Segmenter): an emoji with a skin tone or a letter with a combining accent is one part. Words and characters are inline-blocks, which bidi orders by the direction around them, so the element takes dir="auto" and Latin text on a right-to-left page still reads left to right.',
40
+ 'Stagger 40 ms per word, 25 per character, capped at 600 ms total: long text shrinks the step rather than lengthening the wait.',
41
+ 'The text once, in a visually hidden span (.ma-sr), and aria-hidden parts: one string for assistive tech, whatever element it renders as. Not aria-label on the wrapper: a span, div or p may not be named that way (ARIA 1.2), and screen readers skip it there.',
42
+ 'Composes Reveal instead of re-implementing the observer; costs nothing on a page that already has one.',
43
+ 'mask clips each part to its own line box (clip-path on an inline-block, which keeps the baseline where overflow would move it) and translates the part by 110% inside it: the word is uncovered from the baseline, the way type is set.',
44
+ 'scrub gives every word a slice of the block\'s cover range (20% to 60%, word n from 20% + n·k) on one named view timeline, so a paragraph reads itself in as you scroll and rewinds when you scroll back. No IntersectionObserver fallback on purpose: full words are the honest state where the timeline is missing.',
45
+ ],
46
+ related: ['reveal', 'typewriter', 'rotating-text'],
47
+ intents: ['split text animation', 'animate words on scroll', 'staggered text reveal', 'character reveal', 'text animation astro'],
48
+ replaces: ['SplitText (React Bits)', 'BlurText (React Bits)', 'TextReveal (Magic UI)', 'GSAP SplitText for entrances'],
49
+ fixture: { props: { text: 'Fixture content for SplitText' }, expect: 'ma-sr">Fixture content for SplitText</span>' },
50
+ } satisfies ComponentMeta;
@@ -0,0 +1,92 @@
1
+ ---
2
+ /**
3
+ * Spotlight — a soft radial light follows the cursor over a surface.
4
+ *
5
+ * One pointermove listener writes the cursor position into two custom
6
+ * properties; the light is a pseudo-element gradient positioned by them and
7
+ * faded in by :hover. Nothing is measured on move except the box, and the
8
+ * listener is bound through the runtime so it survives navigation.
9
+ */
10
+ import type { HTMLAttributes, HTMLTag } from 'astro/types';
11
+
12
+ interface Props extends HTMLAttributes<'div'> {
13
+ /** Element to render. */
14
+ as?: HTMLTag;
15
+ /** Radius of the light in px. */
16
+ radius?: number;
17
+ /** Light colour. */
18
+ color?: string;
19
+ /** Strength, 0–1. */
20
+ opacity?: number;
21
+ /** Where the light lives: the surface, or only the card's edge (a ring the same width as `border`). */
22
+ target?: 'surface' | 'border';
23
+ /** Ring width in px for target="border". */
24
+ border?: number;
25
+ }
26
+
27
+ const { as: Tag = 'div', radius = 240, color = 'var(--ma-glow)', opacity = 1, target = 'surface', border = 1, class: className, style, ...rest } = Astro.props;
28
+ const inline = [`--ma-spot-r:${radius}px`, `--ma-spot-c:${color}`, `--ma-spot-o:${opacity}`, target === 'border' && `--ma-spot-w:${border}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
29
+ ---
30
+
31
+ <Tag class:list={['ma-spot', className]} data-ma-spot data-target={target === 'border' ? 'border' : undefined} style={inline} {...rest}>
32
+ <slot />
33
+ </Tag>
34
+
35
+ <style is:global>
36
+ @layer components {
37
+ :where(.ma-spot) {
38
+ position: relative;
39
+ overflow: hidden;
40
+ }
41
+ :where(.ma-spot)::before {
42
+ content: '';
43
+ position: absolute;
44
+ inset: 0;
45
+ pointer-events: none;
46
+ background: radial-gradient(var(--ma-spot-r, 240px) circle at var(--ma-spot-x, 50%) var(--ma-spot-y, 50%), var(--ma-spot-c, var(--ma-glow)), transparent 70%);
47
+ opacity: 0;
48
+ transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
49
+ }
50
+ /* border: the same gradient, kept to a ring by two masks composited with exclude (BorderBeam's ring), over the card's own border */
51
+ :where(.ma-spot[data-target='border'])::before {
52
+ inset: calc(-1 * var(--ma-spot-w, 1px));
53
+ padding: var(--ma-spot-w, 1px);
54
+ border-radius: inherit;
55
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
56
+ -webkit-mask-composite: xor;
57
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
58
+ mask-composite: exclude;
59
+ }
60
+ :where(.ma-spot[data-target='border']) {
61
+ overflow: visible;
62
+ }
63
+ @media (hover: hover) {
64
+ :where(.ma-spot:hover)::before {
65
+ opacity: var(--ma-spot-o, 1);
66
+ }
67
+ }
68
+ @media (prefers-reduced-motion: reduce) {
69
+ :where(.ma-spot)::before {
70
+ transition: none;
71
+ }
72
+ }
73
+ }
74
+ </style>
75
+
76
+ <script>
77
+ import { onMount } from '../lib/runtime';
78
+
79
+ onMount<HTMLElement>('[data-ma-spot]', (el, { signal }) => {
80
+ el.addEventListener(
81
+ 'pointermove',
82
+ (e) => {
83
+ // the rect is in screen pixels, the gradient in the element's own: under a scaled ancestor they differ (a pointer
84
+ // over the element means it has a size, so the ratio is defined)
85
+ const r = el.getBoundingClientRect();
86
+ el.style.setProperty('--ma-spot-x', `${((e.clientX - r.left) * el.offsetWidth) / r.width}px`);
87
+ el.style.setProperty('--ma-spot-y', `${((e.clientY - r.top) * el.offsetHeight) / r.height}px`);
88
+ },
89
+ { signal, passive: true },
90
+ );
91
+ });
92
+ </script>
@@ -0,0 +1,34 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'Spotlight',
5
+ slug: 'spotlight',
6
+ title: 'Spotlight',
7
+ description: 'A soft radial light follows the cursor over a card and fades in only while hovered. One pointermove listener writes two custom properties; the light itself is a CSS gradient. Bound through the shared runtime, so it survives navigation.',
8
+ summary: 'A spotlight card effect: a soft light follows the cursor over a card and fades in only on hover.',
9
+ category: 'pointer',
10
+ tier: 'B',
11
+ trigger: ['pointer', 'hover'],
12
+ support: { baseline: 'widely', needs: ['mask-composite'] },
13
+ readout: '<Spotlight radius={240}>',
14
+ usage: `<Spotlight class="rounded-2xl border p-8">\n <h3>Hover me</h3>\n</Spotlight>\n<Spotlight target="border" color="var(--action)" radius={160} class="rounded-2xl border p-8">\n <h3>The light stays on the edge</h3>\n</Spotlight>`,
15
+ props: [
16
+ { name: 'as', type: 'HTMLTag', default: "'div'", description: 'Element to render.' },
17
+ { name: 'radius', type: 'number', default: '240', description: 'Radius of the light in px.' },
18
+ { name: 'color', type: 'string', default: 'var(--ma-glow)', description: 'Light colour; the token is theme-aware.' },
19
+ { name: 'opacity', type: 'number', default: '1', description: 'Strength while hovered, 0–1.' },
20
+ { name: 'target', type: "'surface' | 'border'", default: "'surface'", description: 'Where the light lives: across the surface, or only on the card\'s edge as a ring that follows the cursor (Magic Card). The same two custom properties drive both; the ring is BorderBeam\'s two-mask trick, zero extra bytes.' },
21
+ { name: 'border', type: 'number', default: '1', description: 'Ring width in px for target="border"; match the card\'s border.' },
22
+ ],
23
+ reducedMotion: 'The light still follows the cursor (direct manipulation, not motion) but no longer fades in and out.',
24
+ clientRouter: 'Bound through the shared runtime: listeners are attached to every instance after each navigation and aborted before the swap.',
25
+ craft: [
26
+ 'The gradient is positioned by custom properties, so the move handler sets two values and never touches layout.',
27
+ 'Fade-in is gated on (hover: hover): on touch there is no cursor to follow, so the light stays off rather than sticking where a finger was.',
28
+ 'passive listener; getBoundingClientRect is the only read, once per move.',
29
+ ],
30
+ related: ['tilt', 'cursor-grid', 'border-beam'],
31
+ intents: ['spotlight card', 'cursor light effect', 'hover glow follows mouse', 'radial gradient follows cursor', 'spotlight hover astro'],
32
+ replaces: ['SpotlightCard (React Bits)', 'MagicCard (Magic UI)', 'CardSpotlight (Aceternity)'],
33
+ fixture: { expect: 'class="ma-spot"' },
34
+ } satisfies ComponentMeta;