@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,301 @@
1
+ ---
2
+ /**
3
+ * StaggerMenu — a burger that opens a full-screen (or top, or side) menu
4
+ * whose items arrive one after another, zero JS.
5
+ *
6
+ * The menu is a native modal <dialog> opened by the invoker command, so the
7
+ * focus trap, Escape, the backdrop click and the focus returning to the
8
+ * burger are the browser's. Each item transitions in from the enter travel
9
+ * with a delay of index × stagger through @starting-style; on close the
10
+ * delays reverse and display/overlay wait through allow-discrete for the
11
+ * last item to leave. The burger and the close button are AnimatedIcons:
12
+ * the X starts as three lines and folds as the menu opens. A link to a place
13
+ * on the same page carries a one-line onclick that closes the menu, or the
14
+ * page would scroll to it behind the open modal.
15
+ */
16
+ import type { HTMLAttributes } from 'astro/types';
17
+ import AnimatedIcons from './AnimatedIcons.astro';
18
+
19
+ interface Item {
20
+ href: string;
21
+ label: string;
22
+ }
23
+
24
+ interface Props extends HTMLAttributes<'div'> {
25
+ /** Dialog id; unique per page. */
26
+ id: string;
27
+ items: Item[];
28
+ /** full: covers the viewport · top: a sheet from the top · right: a panel from the right. */
29
+ side?: 'full' | 'top' | 'right';
30
+ /** href of the current page (aria-current). */
31
+ current?: string;
32
+ /** Accessible name of the trigger and the dialog. */
33
+ label?: string;
34
+ /** Menu colours; default ink on panel text. */
35
+ color?: string;
36
+ textColor?: string;
37
+ /** Add the inline click handler for browsers without invoker commands. */
38
+ polyfill?: boolean;
39
+ }
40
+
41
+ const { id, items, side = 'full', current, label = 'Menu', color, textColor, polyfill = false, class: className, style, ...rest } = Astro.props;
42
+ const vars = [`--ma-smenu-n:${items.length}`, color && `--ma-smenu-bg:${color}`, textColor && `--ma-smenu-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
43
+ const triggerAttrs = { command: 'show-modal', commandfor: id };
44
+ const pad = (n: number) => String(n).padStart(2, '0');
45
+ // a link to a place on this page (a hash on the current path) never unloads it, so nothing would close the modal. An href
46
+ // URL() cannot parse is rendered as given, as before, and is not one of those.
47
+ const here = (href: string) => {
48
+ try {
49
+ const u = new URL(href, Astro.url);
50
+ return !!u.hash && u.pathname === Astro.url.pathname && u.search === Astro.url.search;
51
+ } catch {
52
+ return false;
53
+ }
54
+ };
55
+ ---
56
+
57
+ <div class:list={['ma-smenu', className]} data-side={side} style={vars} {...rest}>
58
+ <button type="button" class="ma-smenu__trigger" aria-haspopup="dialog" aria-label={label} {...triggerAttrs}>
59
+ <AnimatedIcons icon="menu" size="1.25em" />
60
+ </button>
61
+ <dialog id={id} class="ma-smenu__dialog" closedby="any" aria-label={label}>
62
+ <form method="dialog" class="ma-smenu__close">
63
+ <button type="submit" aria-label="Close menu">
64
+ <AnimatedIcons icon="menu" state="on" size="1.25em" />
65
+ </button>
66
+ </form>
67
+ <nav class="ma-smenu__nav" aria-label={label}>
68
+ <ul class="ma-smenu__list">
69
+ {
70
+ items.map((it, i) => (
71
+ <li class="ma-smenu__item" style={`--ma-i:${i}`}>
72
+ <a href={it.href} class="ma-smenu__link" aria-current={it.href === current ? 'page' : undefined} onclick={here(it.href) ? "this.closest('dialog').close()" : undefined}>
73
+ <span class="ma-smenu__index" aria-hidden="true">{pad(i + 1)}</span>
74
+ <span class="ma-smenu__label">{it.label}</span>
75
+ </a>
76
+ </li>
77
+ ))
78
+ }
79
+ </ul>
80
+ </nav>
81
+ {Astro.slots.has('default') && <div class="ma-smenu__foot"><slot /></div>}
82
+ </dialog>
83
+ </div>
84
+ {polyfill && <script is:inline>window.__maCmd||(window.__maCmd=1,document.addEventListener('click',function(e){if('command'in HTMLButtonElement.prototype||e.defaultPrevented)return;var b=e.target.closest('[commandfor]');if(!b)return;var d=document.getElementById(b.getAttribute('commandfor'));if(!d)return;var c=b.getAttribute('command');c==='show-modal'?d.showModal():c==='close'?d.close():c==='toggle-popover'?d.togglePopover():0}))</script>}
85
+
86
+ <style is:global>
87
+ @layer components {
88
+ :where(.ma-smenu) {
89
+ /* the exit has to wait for the last item: the panel's display transition is duration + n × stagger */
90
+ --ma-smenu-total: calc(var(--ma-duration) + var(--ma-smenu-n, 4) * var(--ma-stagger));
91
+ display: inline-block;
92
+ }
93
+ :where(.ma-smenu__trigger),
94
+ :where(.ma-smenu__close button) {
95
+ display: grid;
96
+ place-items: center;
97
+ width: 2.5rem;
98
+ height: 2.5rem;
99
+ border: 1px solid var(--ma-edge);
100
+ border-radius: 999px;
101
+ background: var(--ma-panel);
102
+ color: inherit;
103
+ cursor: pointer;
104
+ transition: background-color var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration) var(--ma-ease);
105
+ }
106
+ :where(.ma-smenu__trigger:hover),
107
+ :where(.ma-smenu__close button:hover) {
108
+ scale: 1.06;
109
+ }
110
+ :where(.ma-smenu__trigger:focus-visible),
111
+ :where(.ma-smenu__close button:focus-visible),
112
+ :where(.ma-smenu__link:focus-visible) {
113
+ outline: 2px solid currentColor;
114
+ outline-offset: 3px;
115
+ }
116
+ /* the panel */
117
+ :where(.ma-smenu__dialog) {
118
+ position: fixed;
119
+ inset: 0;
120
+ margin: 0;
121
+ width: 100%;
122
+ height: 100%;
123
+ max-width: none;
124
+ max-height: none;
125
+ padding: clamp(1.5rem, 6vw, 4rem);
126
+ border: 0;
127
+ background: var(--ma-smenu-bg, var(--ma-ink));
128
+ color: var(--ma-smenu-fg, var(--ma-panel));
129
+ overflow: auto;
130
+ opacity: 0;
131
+ translate: 0 var(--ma-travel-enter, 24px);
132
+ transition:
133
+ opacity var(--ma-duration) var(--ma-ease-in) calc(var(--ma-smenu-n, 4) * var(--ma-stagger)),
134
+ translate var(--ma-duration) var(--ma-ease-in) calc(var(--ma-smenu-n, 4) * var(--ma-stagger)),
135
+ display var(--ma-smenu-total) allow-discrete,
136
+ overlay var(--ma-smenu-total) allow-discrete;
137
+ }
138
+ :where(.ma-smenu__dialog[open]) {
139
+ opacity: 1;
140
+ translate: 0 0;
141
+ transition-delay: 0s;
142
+ transition-timing-function: var(--ma-ease);
143
+ }
144
+ @starting-style {
145
+ :where(.ma-smenu__dialog[open]) {
146
+ opacity: 0;
147
+ translate: 0 var(--ma-travel-enter, 24px);
148
+ }
149
+ }
150
+ :where(.ma-smenu[data-side='top'] .ma-smenu__dialog) {
151
+ height: auto;
152
+ max-height: 90dvh;
153
+ inset: 0 0 auto 0;
154
+ border-radius: 0 0 1.5rem 1.5rem;
155
+ translate: 0 calc(var(--ma-travel-enter, 24px) * -1);
156
+ }
157
+ :where(.ma-smenu[data-side='right'] .ma-smenu__dialog) {
158
+ width: min(26rem, 100%);
159
+ inset: 0 0 0 auto;
160
+ translate: var(--ma-travel-enter, 24px) 0;
161
+ }
162
+ :where(.ma-smenu[data-side='top'] .ma-smenu__dialog[open]),
163
+ :where(.ma-smenu[data-side='right'] .ma-smenu__dialog[open]) {
164
+ translate: 0 0;
165
+ }
166
+ @starting-style {
167
+ :where(.ma-smenu[data-side='top'] .ma-smenu__dialog[open]) {
168
+ translate: 0 calc(var(--ma-travel-enter, 24px) * -1);
169
+ }
170
+ :where(.ma-smenu[data-side='right'] .ma-smenu__dialog[open]) {
171
+ translate: var(--ma-travel-enter, 24px) 0;
172
+ }
173
+ }
174
+ :where(.ma-smenu__dialog)::backdrop {
175
+ background: var(--ma-scrim);
176
+ opacity: 0;
177
+ transition:
178
+ opacity var(--ma-duration) var(--ma-ease-out),
179
+ display var(--ma-smenu-total) allow-discrete,
180
+ overlay var(--ma-smenu-total) allow-discrete;
181
+ }
182
+ :where(.ma-smenu__dialog[open])::backdrop {
183
+ opacity: 1;
184
+ }
185
+ @starting-style {
186
+ :where(.ma-smenu__dialog[open])::backdrop {
187
+ opacity: 0;
188
+ }
189
+ }
190
+ :where(.ma-smenu__close) {
191
+ position: absolute;
192
+ inset-block-start: 1.25rem;
193
+ inset-inline-end: 1.25rem;
194
+ }
195
+ /* the X starts as the burger and folds as the panel arrives */
196
+ @starting-style {
197
+ :where(.ma-smenu__dialog[open] .ma-smenu__close .ma-icon__a),
198
+ :where(.ma-smenu__dialog[open] .ma-smenu__close .ma-icon__c) {
199
+ translate: 0 0;
200
+ rotate: 0deg;
201
+ }
202
+ :where(.ma-smenu__dialog[open] .ma-smenu__close .ma-icon__b) {
203
+ scale: 1 1;
204
+ opacity: 1;
205
+ }
206
+ }
207
+ :where(.ma-smenu__nav) {
208
+ display: grid;
209
+ align-content: center;
210
+ min-height: 100%;
211
+ }
212
+ :where(.ma-smenu[data-side='top'] .ma-smenu__nav),
213
+ :where(.ma-smenu[data-side='right'] .ma-smenu__nav) {
214
+ min-height: 0;
215
+ padding-top: 2.5rem;
216
+ }
217
+ :where(.ma-smenu__list) {
218
+ display: grid;
219
+ gap: 0.25em;
220
+ margin: 0;
221
+ padding: 0;
222
+ list-style: none;
223
+ }
224
+ /* the items: in order on open, reversed on close, each on the stagger token */
225
+ :where(.ma-smenu__item) {
226
+ opacity: 0;
227
+ translate: 0 var(--ma-travel-enter, 24px);
228
+ transition:
229
+ opacity var(--ma-duration) var(--ma-ease-out),
230
+ translate var(--ma-duration) var(--ma-ease);
231
+ transition-delay: calc((var(--ma-smenu-n, 4) - 1 - var(--ma-i, 0)) * var(--ma-stagger));
232
+ }
233
+ :where(.ma-smenu__dialog[open] .ma-smenu__item) {
234
+ opacity: 1;
235
+ translate: 0 0;
236
+ transition-delay: calc(var(--ma-i, 0) * var(--ma-stagger) + var(--ma-duration-fast));
237
+ }
238
+ @starting-style {
239
+ :where(.ma-smenu__dialog[open] .ma-smenu__item) {
240
+ opacity: 0;
241
+ translate: 0 var(--ma-travel-enter, 24px);
242
+ }
243
+ }
244
+ :where(.ma-smenu__link) {
245
+ display: flex;
246
+ align-items: baseline;
247
+ gap: 0.75em;
248
+ padding: 0.15em 0;
249
+ color: inherit;
250
+ text-decoration: none;
251
+ font-size: clamp(2rem, 7vw, 4.5rem);
252
+ font-weight: 700;
253
+ letter-spacing: -0.03em;
254
+ line-height: 1.05;
255
+ opacity: 0.85;
256
+ transition:
257
+ opacity var(--ma-duration-fast) var(--ma-ease-out),
258
+ translate var(--ma-duration) var(--ma-ease);
259
+ }
260
+ :where(.ma-smenu[data-side='top'] .ma-smenu__link),
261
+ :where(.ma-smenu[data-side='right'] .ma-smenu__link) {
262
+ font-size: clamp(1.5rem, 4vw, 2.25rem);
263
+ }
264
+ :where(.ma-smenu__link:hover),
265
+ :where(.ma-smenu__link[aria-current='page']) {
266
+ opacity: 1;
267
+ translate: 0.25em 0;
268
+ }
269
+ :where(.ma-smenu__index) {
270
+ font-family: ui-monospace, monospace;
271
+ font-size: 0.75rem;
272
+ font-weight: 400;
273
+ letter-spacing: 0.1em;
274
+ opacity: var(--ma-dim, 0.7);
275
+ }
276
+ :where(.ma-smenu__foot) {
277
+ margin-top: 2rem;
278
+ font-size: 0.875rem;
279
+ opacity: 0.7;
280
+ }
281
+ @media (prefers-reduced-motion: reduce) {
282
+ :where(.ma-smenu__dialog),
283
+ :where(.ma-smenu__dialog[open]),
284
+ :where(.ma-smenu__item),
285
+ :where(.ma-smenu__dialog[open] .ma-smenu__item) {
286
+ translate: 0 0;
287
+ transition-duration: var(--ma-duration-fast);
288
+ transition-delay: 0s;
289
+ transition-timing-function: linear;
290
+ }
291
+ :where(.ma-smenu) {
292
+ --ma-smenu-total: var(--ma-duration-fast);
293
+ }
294
+ :where(.ma-smenu__link),
295
+ :where(.ma-smenu__trigger),
296
+ :where(.ma-smenu__close button) {
297
+ transition: none;
298
+ }
299
+ }
300
+ }
301
+ </style>
@@ -0,0 +1,39 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'StaggerMenu',
5
+ slug: 'stagger-menu',
6
+ title: 'Stagger Menu',
7
+ description: 'A burger that opens a full-screen menu (or a top sheet, or a side panel) whose items arrive one after another and leave in reverse. No script: a native modal dialog opened by the invoker command (focus trap, Escape, backdrop click and the focus returning to the burger are the browser\'s), and each item is a @starting-style transition delayed by its index on the stagger token. A link to a place on the same page carries a one-line onclick that closes the menu.',
8
+ summary: 'A full-screen menu from a burger button, its items arriving one after another and leaving in reverse.',
9
+ category: 'ui',
10
+ tier: 'A',
11
+ trigger: ['click'],
12
+ support: { baseline: 'newly', needs: ['command', 'starting-style', 'allow-discrete', 'dialog'], fallback: 'without invoker commands the burger needs polyfill (an inline handler of 403 B raw); without @starting-style the menu opens without the stagger; without closedby (Safari) a backdrop click does nothing, and Escape and the close button still close it' },
13
+ readout: '<StaggerMenu side="full">',
14
+ usage: `<StaggerMenu\n id="menu"\n items={[\n { href: '/', label: 'Home' },\n { href: '/work/', label: 'Work' },\n { href: '/studio/', label: 'Studio' },\n { href: '/contact/', label: 'Contact' },\n ]}\n current="/work/"\n>\n <p>hello@studio.example · Jakarta</p>\n</StaggerMenu>`,
15
+ props: [
16
+ { name: 'id', type: 'string', description: 'Dialog id; unique per page.' },
17
+ { name: 'items', type: '{ href: string; label: string }[]', description: 'The links, numbered in order. A link to a place on the current page (#…) closes the menu when followed, through an onclick of 30 B raw rendered on that link only.' },
18
+ { name: 'side', type: "'full' | 'top' | 'right'", default: "'full'", description: 'full covers the viewport with big type; top is a sheet from the top edge; right a panel from the right.' },
19
+ { name: 'current', type: 'string', description: 'href of the current page (aria-current="page").' },
20
+ { name: 'label', type: 'string', default: "'Menu'", description: 'Accessible name of the burger and the dialog.' },
21
+ { name: 'color', type: 'string', description: 'Panel colour; default the ink token.' },
22
+ { name: 'textColor', type: 'string', description: 'Text colour; default the panel token.' },
23
+ { name: 'polyfill', type: 'boolean', default: 'false', description: 'Inline the invoker click handler (403 B raw) for browsers without command / commandfor (Safari < 26.2, Firefox < 144); without it the burger opens nothing there.' },
24
+ ],
25
+ reducedMotion: 'The panel and every item cross-fade together in 150 ms; no travel, no stagger.',
26
+ clientRouter: 'Native; nothing to rebind. Following a link to another page closes the menu with the page; mark the new page with current. A link to a place on the same page closes it through its onclick.',
27
+ craft: [
28
+ 'A modal <dialog> is the cheapest focus trap there is: Escape, the backdrop click (closedby="any", Chrome and Firefox) and the focus returning to the burger cost nothing and cannot drift from the platform.',
29
+ 'A link to #section on the same page never unloads it, so the modal would stay over the section it scrolled to. Those links, found at build by comparing the href with the page\'s own URL, carry onclick="this.closest(\'dialog\').close()"; the menu folds away while the page scrolls.',
30
+ 'Each item\'s delay is index × the stagger token on the way in and (n − 1 − index) × stagger on the way out, so the menu folds back the way it unfolded; the panel\'s own fade waits n × stagger so it never leaves before its last item.',
31
+ 'display and overlay transition with allow-discrete for duration + n × stagger: the top layer holds the panel exactly as long as the exit takes.',
32
+ 'The X is an AnimatedIcons menu in its "on" state whose paths start in the burger shape through @starting-style, so opening reads as the burger folding into the X even though the two are different elements.',
33
+ 'Big type is the whole design of a fullscreen menu: clamp(2rem, 7vw, 4.5rem), tight tracking, an index in mono at half opacity. The top and right sides step it down.',
34
+ ],
35
+ related: ['dialog', 'animated-icons', 'drawer', 'text-roll'],
36
+ intents: ['fullscreen menu', 'staggered menu items', 'burger menu overlay', 'hamburger menu animation astro', 'menu items animate in one by one', 'mobile nav overlay'],
37
+ replaces: ['StaggerMenu (React Bits)', 'FullscreenMenu (Hover.dev)', 'Sheet (shadcn / Radix)', 'AnimatePresence menu (Framer Motion)'],
38
+ fixture: { props: { id: 'ma-fixture-smenu', items: [{ href: '/', label: 'Fixture content for StaggerMenu' }, { href: '/two/', label: 'Two' }] }, expect: 'Fixture content for StaggerMenu' },
39
+ } satisfies ComponentMeta;
@@ -0,0 +1,148 @@
1
+ ---
2
+ /**
3
+ * StatefulButton — one button, four faces: idle, pending (a spinner),
4
+ * done (a check that draws itself), error. Zero JS of its own: the state is
5
+ * `data-state` on the button — set by the `state` prop, by your own script,
6
+ * or inherited from its form: `data-pending` shows the pending face, and
7
+ * `data-state` done, exists or error the matching one (exists shows done),
8
+ * which is what @moonarc/core/form writes. The faces sit in one row; the inactive ones are clipped to zero
9
+ * width and the active one opens to a bound derived from its label length
10
+ * (the RotatingText `fluid` technique), so the button's width follows the
11
+ * label on the preset's spring with nothing measured. Inactive faces are
12
+ * visibility: hidden, so the button is named by the face that shows. The
13
+ * button is not a live region: the announcement is the form's role="status"
14
+ * (which the form lib requires) or the caller's. The `pending` state renders
15
+ * aria-disabled, not disabled, so the focus stays on the button; neither it
16
+ * nor a form's `data-pending` can stop a submit (no script here), so the
17
+ * handler that sets them ignores submits meanwhile.
18
+ */
19
+ import type { HTMLAttributes } from 'astro/types';
20
+ import Spinner from './Spinner.astro';
21
+ import AnimatedIcons from './AnimatedIcons.astro';
22
+
23
+ type State = 'idle' | 'pending' | 'done' | 'error';
24
+
25
+ interface Props extends HTMLAttributes<'button'> {
26
+ /** Current face. */
27
+ state?: State;
28
+ /** Face labels. The default slot, when given, replaces the idle label. */
29
+ labels?: Partial<Record<State, string>>;
30
+ type?: 'button' | 'submit' | 'reset';
31
+ }
32
+
33
+ const { state = 'idle', labels = {}, type = 'submit', class: className, style, ...rest } = Astro.props;
34
+ const text: Record<State, string> = { idle: labels.idle ?? 'Submit', pending: labels.pending ?? 'Sending', done: labels.done ?? 'Done', error: labels.error ?? 'Try again' };
35
+ const hasSlot = Astro.slots.has('default');
36
+ // the open width bound per face: 0.62em per character + the icon and its gap — above any Latin label, never far above it.
37
+ // A wide character (CJK, Hangul, kana, full-width forms, emoji) is a full em: at 0.62em "送信中" was cut to its first two.
38
+ const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]|\p{Extended_Pictographic}|[\u{20000}-\u{3fffd}]/u;
39
+ const bound = (label: string, icon: boolean) => `calc(${[...label].reduce((w, c) => w + (WIDE.test(c) ? 1 : 0.62), 0).toFixed(2)}em + ${icon ? '1.6em' : '0em'})`;
40
+ ---
41
+
42
+ <button class:list={['ma-stateful', className]} type={type} data-state={state} aria-disabled={state === 'pending' ? 'true' : undefined} style={style} {...rest}>
43
+ <span class="ma-stateful__face" data-face="idle" style={`--ma-stateful-w:${hasSlot ? 'none' : bound(text.idle, false)}`}>{hasSlot ? <slot /> : text.idle}</span>
44
+ <span class="ma-stateful__face" data-face="pending" style={`--ma-stateful-w:${bound(text.pending, true)}`}><Spinner size={16} width={2} label="" /><span>{text.pending}</span></span>
45
+ <span class="ma-stateful__face" data-face="done" style={`--ma-stateful-w:${bound(text.done, true)}`}><AnimatedIcons icon="check" state="on" /><span>{text.done}</span></span>
46
+ <span class="ma-stateful__face" data-face="error" style={`--ma-stateful-w:${bound(text.error, true)}`}><AnimatedIcons icon="plus" state="on" /><span>{text.error}</span></span>
47
+ </button>
48
+
49
+ <style is:global>
50
+ @layer components {
51
+ :where(.ma-stateful) {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ padding: 0.65em 1.2em;
56
+ border: 1px solid var(--ma-edge);
57
+ border-radius: var(--ma-stateful-radius, var(--ma-radius-md, 0.6em));
58
+ background: var(--ma-ink);
59
+ color: var(--ma-panel);
60
+ font: inherit;
61
+ font-weight: 500;
62
+ line-height: 1.2;
63
+ white-space: nowrap;
64
+ cursor: pointer;
65
+ transition:
66
+ background-color var(--ma-duration-fast) var(--ma-ease-out),
67
+ opacity var(--ma-duration-fast) var(--ma-ease-out);
68
+ }
69
+ :where(.ma-stateful[aria-disabled='true']) {
70
+ cursor: progress;
71
+ }
72
+ :where(.ma-stateful:focus-visible) {
73
+ outline: 2px solid currentColor;
74
+ outline-offset: 3px;
75
+ }
76
+ /* inactive faces leave the accessibility tree (the CopyButton rule), so the button is named by the one that shows.
77
+ visibility transitions with the fade on ease-out: an arriving face counts at
78
+ once, a leaving one until it has faded; an overshooting preset curve would flip it mid-fade */
79
+ :where(.ma-stateful__face) {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 0.5em;
83
+ max-width: 0;
84
+ opacity: 0;
85
+ visibility: hidden;
86
+ overflow: clip;
87
+ translate: 0 var(--ma-travel-hover);
88
+ transition:
89
+ max-width var(--ma-duration) var(--ma-ease),
90
+ opacity var(--ma-duration) var(--ma-ease),
91
+ translate var(--ma-duration) var(--ma-ease),
92
+ visibility var(--ma-duration) var(--ma-ease-out);
93
+ }
94
+ :where(.ma-stateful__face > *) {
95
+ flex: none;
96
+ }
97
+ :where(.ma-stateful[data-state='idle'] [data-face='idle']),
98
+ :where(.ma-stateful[data-state='pending'] [data-face='pending']),
99
+ :where(.ma-stateful[data-state='done'] [data-face='done']),
100
+ :where(.ma-stateful[data-state='error'] [data-face='error']) {
101
+ max-width: var(--ma-stateful-w, none);
102
+ opacity: 1;
103
+ visibility: visible;
104
+ translate: 0 0;
105
+ }
106
+ /* a form that says where it is: the button follows, whatever its own state says. data-pending shows the pending face,
107
+ data-state done, exists or error the matching one (exists is done: the address was already in), the attributes the
108
+ form lib writes. pointer-events stops the mouse only; Enter and Space still submit, so the handler that sets
109
+ data-pending ignores submits while it is there (the form lib does) */
110
+ :where(form:is([data-pending], [data-state='done'], [data-state='exists'], [data-state='error']) .ma-stateful [data-face]) {
111
+ max-width: 0;
112
+ opacity: 0;
113
+ visibility: hidden;
114
+ }
115
+ :where(form[data-pending] .ma-stateful [data-face='pending']),
116
+ :where(form:is([data-state='done'], [data-state='exists']):not([data-pending]) .ma-stateful [data-face='done']),
117
+ :where(form[data-state='error']:not([data-pending]) .ma-stateful [data-face='error']) {
118
+ max-width: var(--ma-stateful-w, none);
119
+ opacity: 1;
120
+ visibility: visible;
121
+ translate: 0 0;
122
+ }
123
+ :where(form[data-pending] .ma-stateful) {
124
+ pointer-events: none;
125
+ }
126
+ /* the state tints are hooks: on a coloured ground a caller points --ma-stateful-done / --ma-stateful-error at a colour
127
+ its label keeps 4.5 : 1 on (the words carry the state either way) */
128
+ :where(.ma-stateful[data-state='done']) {
129
+ background: var(--ma-stateful-done, color-mix(in srgb, var(--ma-ink) 88%, #7cc67c));
130
+ }
131
+ :where(.ma-stateful[data-state='error']) {
132
+ background: var(--ma-stateful-error, color-mix(in srgb, var(--ma-ink) 84%, #e0563f));
133
+ }
134
+ /* after the button's own: the form's state wins, as it does for the faces */
135
+ :where(form:is([data-state='done'], [data-state='exists']) .ma-stateful) {
136
+ background: var(--ma-stateful-done, color-mix(in srgb, var(--ma-ink) 88%, #7cc67c));
137
+ }
138
+ :where(form[data-state='error'] .ma-stateful) {
139
+ background: var(--ma-stateful-error, color-mix(in srgb, var(--ma-ink) 84%, #e0563f));
140
+ }
141
+ @media (prefers-reduced-motion: reduce) {
142
+ :where(.ma-stateful__face) {
143
+ transition: none;
144
+ translate: 0 0;
145
+ }
146
+ }
147
+ }
148
+ </style>
@@ -0,0 +1,35 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'StatefulButton',
5
+ slug: 'stateful-button',
6
+ title: 'Stateful Button',
7
+ description: 'One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label. The state is data-state on the button, set by the prop, by your script, or inherited from its form: data-pending and data-state done, exists or error, the attributes @moonarc/core/form writes. The faces are clipped to a bound derived from the label, so nothing is measured, and only the face that shows is in the accessibility tree. Zero JavaScript of its own.',
8
+ summary: 'A submit button with idle, pending, done and error states, a spinner and a check, its width following the label.',
9
+ category: 'ui',
10
+ tier: 'A',
11
+ trigger: ['click'],
12
+ support: { baseline: 'widely', needs: [] },
13
+ readout: '<StatefulButton state="pending">',
14
+ usage: `<form data-signup>\n <input type="email" name="email" required />\n <StatefulButton labels={{ idle: 'Subscribe', pending: 'Sending', done: 'You are in', error: 'Try again' }} />\n <p role="status"></p>\n</form>\n\n<script>\n const form = document.querySelector('[data-signup]');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n // data-pending cannot stop Enter or Space from sending again, so a second submit is ignored here\n if (form.hasAttribute('data-pending')) return;\n form.dataset.pending = '';\n delete form.dataset.state;\n let ok = false;\n try {\n ok = (await fetch('/api/subscribe', { method: 'POST', body: new FormData(form) })).ok;\n } catch {}\n delete form.dataset.pending;\n form.dataset.state = ok ? 'done' : 'error';\n // the button is not a live region: say it in the form's role="status"\n form.querySelector('[role="status"]').textContent = ok ? 'You are in.' : 'That did not go through. Try again.';\n });\n</script>`,
15
+ props: [
16
+ { name: 'state', type: "'idle' | 'pending' | 'done' | 'error'", default: "'idle'", description: 'Current face. The pending state renders aria-disabled="true", not disabled, so the focus stays on the button; a data-state your script writes later changes the face only, so write aria-disabled with it. Inside a form, the form\'s data-pending and data-state win.' },
17
+ { name: 'labels', type: 'Partial<Record<State, string>>', default: 'Submit / Sending / Done / Try again', description: 'Face labels. The default slot replaces the idle label.' },
18
+ { name: 'type', type: "'button' | 'submit' | 'reset'", default: "'submit'", description: 'Button type.' },
19
+ ],
20
+ reducedMotion: 'Faces switch at once; the spinner slows and the check is simply drawn.',
21
+ clientRouter: 'CSS-only; the state attribute is on the element and goes with the page.',
22
+ craft: [
23
+ 'Width follows the label without measuring: each face opens to a max-width derived from its characters plus the icon, a bound just above the label, so the transition ends where the text ends. This is the RotatingText fluid technique. interpolate-size would not have helped: auto → auto never transitions.',
24
+ 'A form that says data-pending, or data-state done, exists or error, wins over the button\'s own state, so a form bound with @moonarc/core/form drives the faces with no code (exists shows the done face). The mouse cannot press it while the form is pending. Enter in a field and Space on the button still submit, because CSS cannot disable a button, so the handler that sets data-pending returns early while it is there, as the form lib and the usage do.',
25
+ 'Pending is aria-disabled, not disabled: a disabled button loses the focus the moment it is pressed (Chromium sends it to the body), and a keyboard reader would have to find the form again.',
26
+ 'The spinner is the library\'s Spinner and the check is AnimatedIcons, so the three motions share one preset.',
27
+ 'Inactive faces are visibility: hidden, which takes them out of the accessibility tree, so the button is named by the face that shows. It is not a live region: a live button would announce its own name each time a face arrives, on top of the form\'s role="status" that says the same thing in a sentence. visibility transitions with the fade on ease-out: an arriving face counts at once, a leaving one until it has faded.',
28
+ 'The tints and the corner are hooks: --ma-stateful-done and --ma-stateful-error for a coloured ground where the label must keep 4.5 : 1, --ma-stateful-radius over the theme\'s --ma-radius-md.',
29
+ 'The width bound counts 0.62 em per Latin character and a full em for a wide one (CJK, Hangul, kana, full-width forms, emoji), so a Japanese or Korean label opens to its whole length.',
30
+ ],
31
+ related: ['spinner', 'animated-icons', 'copy-button'],
32
+ intents: ['loading button', 'submit button with spinner', 'button success state', 'stateful button', 'button that shows sending and done'],
33
+ replaces: ['StatefulButton (Aceternity)', 'LoadingButton (shadcn examples)'],
34
+ fixture: { props: { labels: { idle: 'Fixture content for StatefulButton' } }, expect: 'Fixture content for StatefulButton' },
35
+ } satisfies ComponentMeta;