@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: 'AnimatedBeam',
5
+ slug: 'animated-beam',
6
+ title: 'Animated Beam',
7
+ description: 'Sends packets of light along curves between nodes. The layout is a preset (satellites around a hub, a row, or a fan into one), computed on the server in percent, so the SVG paths and the slotted nodes share coordinates and nothing is measured. Each beam is one dash, sized as a fraction of its path\'s on-screen length (computed in CSS from the box\'s container units), whose offset runs from start to past the end, with a fainter tail behind it. Zero JS.',
8
+ summary: 'Beams of light that travel along curved lines between nodes: a hub with satellites, a row, or a fan into one.',
9
+ category: 'background',
10
+ tier: 'A',
11
+ trigger: ['always'],
12
+ support: { baseline: 'widely', needs: ['container-units', 'css-exp'], fallback: 'without container units or hypot() the lines draw solid in the packet colour and nothing travels' },
13
+ readout: '<AnimatedBeam layout="hub" nodes={5}>',
14
+ usage: `<div class="relative h-80">\n <AnimatedBeam layout="hub" nodes={5} color="var(--action)">\n <img slot="hub" src="/logo.svg" alt="" />\n <Icon slot="node-1" name="slack" />\n <Icon slot="node-2" name="notion" />\n <Icon slot="node-3" name="github" />\n <Icon slot="node-4" name="figma" />\n <Icon slot="node-5" name="linear" />\n </AnimatedBeam>\n</div>`,
15
+ props: [
16
+ { name: 'layout', type: "'hub' | 'row' | 'fan'", default: "'hub'", description: 'hub: one centre (slot hub) with `nodes` satellites on an ellipse · row: `nodes` in a line, left to right · fan: `nodes` on the left into one on the right (slot hub).' },
17
+ { name: 'nodes', type: 'number', default: '5', description: 'Satellites or steps, 2–8. Slots node-1 … node-8; an empty slot draws a dot.' },
18
+ { name: 'duration', type: 'number', default: '3', description: 'Seconds a packet takes to travel one path.' },
19
+ { name: 'color', type: 'string', default: "'currentColor'", description: 'Packet colour.' },
20
+ { name: 'track', type: 'string', default: 'var(--ma-edge)', description: 'The line behind the packets.' },
21
+ { name: 'curve', type: 'number', default: '0.35', description: 'Bend of the curves, 0 straight to 1 deeply curved; sides alternate so a hub reads as a flower.' },
22
+ { name: 'length', type: 'number', default: '0.18', description: 'Packet length as a fraction of its path.' },
23
+ { name: 'reverse', type: 'boolean', default: 'false', description: 'Flow away from the hub (row: right to left).' },
24
+ { name: 'width', type: 'number', default: '1.5', description: 'Line width in px, kept in px by vector-effect while the viewBox stretches.' },
25
+ ],
26
+ reducedMotion: 'The lines stay, drawn solid at 60 % in the packet colour; nothing travels.',
27
+ clientRouter: 'CSS-only; nothing to rebind.',
28
+ craft: [
29
+ 'Positions and paths are computed once on the server in percent of the box; nodes are absolutely positioned with the same numbers, so the component is responsive with no ResizeObserver and no getBoundingClientRect. The Magic UI original measures on every resize.',
30
+ 'Every dash is a fraction of its own path, so a short path and a long one carry packets of the same relative length. The length is CSS: one user unit is 1cqw across and 1cqh down, and eight chords of each curve, computed on the server, sum as hypot() terms to within half a percent of the arc at any box size. pathLength="1" cannot do it: under non-scaling-stroke all three engines lay its units along the screen-space path, and a stretched box drew a row of short dashes. The size container is a box around the drawing alone, so a slotted node keeps its own container queries and cq units.',
31
+ 'The gap in the dash pattern is twice the path\'s length, so exactly one packet is on a path at a time; the tail is a longer dash a quarter as bright with its end pinned to the packet\'s.',
32
+ 'Each path\'s delay is its index over the count of the period, which spreads the packets evenly instead of using a fixed stagger.',
33
+ 'preserveAspectRatio="none" stretches the drawing to the box; vector-effect: non-scaling-stroke keeps the line width constant, and puts the dashes in screen px with it.',
34
+ ],
35
+ related: ['orbiting-circles', 'border-beam', 'tracing-beam'],
36
+ intents: ['animated beam between elements', 'integration diagram animated lines', 'connecting beams hub and spokes', 'data flow lines animation', 'animated connection lines svg'],
37
+ replaces: ['AnimatedBeam (Magic UI)', 'Connection beams (Aceternity)'],
38
+ fixture: { props: { layout: 'hub', nodes: 4 }, expect: 'class="ma-beams"' },
39
+ } satisfies ComponentMeta;
@@ -0,0 +1,193 @@
1
+ ---
2
+ /**
3
+ * AnimatedIcons — six line icons, each with a second state it morphs into,
4
+ * zero JS: menu ↔ close, plus ↔ close, copy ↔ check, play ↔ pause,
5
+ * sun ↔ moon, and a check that draws itself. Every stroke is its own path
6
+ * moved by the individual transform properties (translate, rotate, scale)
7
+ * plus opacity and a dash offset, so each pair works in every engine; play
8
+ * ↔ pause additionally morphs its two halves with `d: path()` where the
9
+ * property exists (Chrome, Firefox) and cross-fades elsewhere. The state is
10
+ * the `state` prop, or read from the button around it: aria-pressed,
11
+ * aria-expanded, data-state="done" or data-on — so CopyButton, ThemeToggle
12
+ * and a burger menu switch their icon without a line of script. Any such
13
+ * ancestor counts, however far out; state="off" pins the icon off.
14
+ */
15
+ import type { HTMLAttributes } from 'astro/types';
16
+
17
+ type Icon = 'menu' | 'copy' | 'play' | 'plus' | 'check' | 'sun';
18
+
19
+ interface Props extends HTMLAttributes<'span'> {
20
+ /** Which pair. */
21
+ icon: Icon;
22
+ /** Second state; 'on' or 'off' pins it. Unset: on while any ancestor has aria-pressed="true", aria-expanded="true", data-state="done" or data-on. */
23
+ state?: 'on' | 'off';
24
+ /** Any CSS length; default 1em, so it follows the button's font-size. */
25
+ size?: string;
26
+ /** Stroke width on the 24-unit grid. */
27
+ stroke?: number;
28
+ }
29
+
30
+ const { icon, state, size = '1em', stroke = 2, class: className, style, ...rest } = Astro.props;
31
+ const vars = [`--ma-icon-size:${size}`, `--ma-icon-stroke:${stroke}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
32
+ ---
33
+
34
+ <span class:list={['ma-icon', className]} data-icon={icon} data-on={state === 'on' ? '' : undefined} data-off={state === 'off' ? '' : undefined} aria-hidden="true" style={vars} {...rest}>
35
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
36
+ {icon === 'menu' && (
37
+ <>
38
+ <path class="ma-icon__a" d="M4 7h16" />
39
+ <path class="ma-icon__b" d="M4 12h16" />
40
+ <path class="ma-icon__c" d="M4 17h16" />
41
+ </>
42
+ )}
43
+ {icon === 'plus' && (
44
+ <>
45
+ <path class="ma-icon__a" d="M12 4v16" />
46
+ <path class="ma-icon__b" d="M4 12h16" />
47
+ </>
48
+ )}
49
+ {icon === 'check' && <path class="ma-icon__a" d="M5 12l4.5 4.5L19 7" pathLength="1" />}
50
+ {icon === 'copy' && (
51
+ <>
52
+ <rect class="ma-icon__a" x="9" y="9" width="11" height="11" rx="2" />
53
+ <path class="ma-icon__b" d="M5 15V6a2 2 0 0 1 2-2h9" />
54
+ <path class="ma-icon__c" d="M5 12l4.5 4.5L19 7" pathLength="1" />
55
+ </>
56
+ )}
57
+ {icon === 'play' && (
58
+ <>
59
+ <path class="ma-icon__a" d="M7 5 L12 8 L12 16 L7 19 Z" fill="currentColor" />
60
+ <path class="ma-icon__b" d="M12 8 L17 12 L17 12 L12 16 Z" fill="currentColor" />
61
+ <path class="ma-icon__c" d="M6 5 L10 5 L10 19 L6 19 Z" fill="currentColor" />
62
+ <path class="ma-icon__d" d="M14 5 L18 5 L18 19 L14 19 Z" fill="currentColor" />
63
+ </>
64
+ )}
65
+ {icon === 'sun' && (
66
+ <>
67
+ <circle class="ma-icon__a" cx="12" cy="12" r="4" />
68
+ <path class="ma-icon__b" d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
69
+ <path class="ma-icon__c" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
70
+ </>
71
+ )}
72
+ </svg>
73
+ </span>
74
+
75
+ <style is:global>
76
+ @layer components {
77
+ :where(.ma-icon) {
78
+ display: inline-block;
79
+ width: var(--ma-icon-size, 1em);
80
+ height: var(--ma-icon-size, 1em);
81
+ vertical-align: -0.125em;
82
+ flex: none;
83
+ }
84
+ :where(.ma-icon > svg) {
85
+ display: block;
86
+ width: 100%;
87
+ height: 100%;
88
+ overflow: visible;
89
+ stroke-width: var(--ma-icon-stroke, 2);
90
+ }
91
+ :where(.ma-icon > svg > *) {
92
+ transform-box: fill-box;
93
+ transform-origin: center;
94
+ transition:
95
+ translate var(--ma-duration) var(--ma-ease),
96
+ rotate var(--ma-duration) var(--ma-ease),
97
+ scale var(--ma-duration) var(--ma-ease),
98
+ opacity var(--ma-duration) var(--ma-ease),
99
+ stroke-dashoffset var(--ma-duration) var(--ma-ease),
100
+ d var(--ma-duration) var(--ma-ease);
101
+ }
102
+
103
+ /* off states that are not the drawn attribute */
104
+ :where(.ma-icon[data-icon='check'] .ma-icon__a),
105
+ :where(.ma-icon[data-icon='copy'] .ma-icon__c) {
106
+ stroke-dasharray: 1;
107
+ stroke-dashoffset: 1;
108
+ }
109
+ :where(.ma-icon[data-icon='play'] .ma-icon__c),
110
+ :where(.ma-icon[data-icon='play'] .ma-icon__d),
111
+ :where(.ma-icon[data-icon='sun'] .ma-icon__c) {
112
+ opacity: 0;
113
+ scale: 0.6;
114
+ }
115
+ :where(.ma-icon[data-icon='sun'] .ma-icon__c) {
116
+ rotate: -90deg;
117
+ }
118
+
119
+ /* on: the prop, or any ancestor in an on state, unless the prop pins it off */
120
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='menu'] .ma-icon__a) {
121
+ translate: 0 5px;
122
+ rotate: 45deg;
123
+ }
124
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='menu'] .ma-icon__b) {
125
+ scale: 0 1;
126
+ opacity: 0;
127
+ }
128
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='menu'] .ma-icon__c) {
129
+ translate: 0 -5px;
130
+ rotate: -45deg;
131
+ }
132
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='plus'] > svg > *) {
133
+ rotate: 45deg;
134
+ }
135
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='check'] .ma-icon__a),
136
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__c) {
137
+ stroke-dashoffset: 0;
138
+ }
139
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__c) {
140
+ transition-delay: calc(var(--ma-duration) / 3);
141
+ }
142
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__a),
143
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='copy'] .ma-icon__b) {
144
+ scale: 0.5;
145
+ opacity: 0;
146
+ }
147
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__a),
148
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__b) {
149
+ opacity: 0;
150
+ scale: 0.6;
151
+ }
152
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__c),
153
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__d) {
154
+ opacity: 1;
155
+ scale: 1;
156
+ }
157
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='sun'] .ma-icon__a),
158
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='sun'] .ma-icon__b) {
159
+ opacity: 0;
160
+ scale: 0.5;
161
+ rotate: 90deg;
162
+ }
163
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='sun'] .ma-icon__c) {
164
+ opacity: 1;
165
+ scale: 1;
166
+ rotate: 0deg;
167
+ }
168
+
169
+ /* play ↔ pause: where `d` is a CSS property the two halves morph into the two bars; the bar paths are then never shown */
170
+ @supports (d: path('M0 0h1')) {
171
+ :where(.ma-icon[data-icon='play'] .ma-icon__c),
172
+ :where(.ma-icon[data-icon='play'] .ma-icon__d) {
173
+ display: none;
174
+ }
175
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__a) {
176
+ d: path('M6 5 L10 5 L10 19 L6 19 Z');
177
+ opacity: 1;
178
+ scale: 1;
179
+ }
180
+ :where(:is(.ma-icon[data-on], :is([aria-pressed='true'], [aria-expanded='true'], [data-state='done'], [data-on]) .ma-icon:not([data-off]))[data-icon='play'] .ma-icon__b) {
181
+ d: path('M14 5 L18 5 L18 19 L14 19 Z');
182
+ opacity: 1;
183
+ scale: 1;
184
+ }
185
+ }
186
+
187
+ @media (prefers-reduced-motion: reduce) {
188
+ :where(.ma-icon > svg > *) {
189
+ transition: none;
190
+ }
191
+ }
192
+ }
193
+ </style>
@@ -0,0 +1,34 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'AnimatedIcons',
5
+ slug: 'animated-icons',
6
+ title: 'Animated Icons',
7
+ description: 'Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself. Each stroke is its own path moved by translate, rotate, scale, opacity and dash offset, so every pair works in every engine; play/pause also morphs with d: path() where the property exists. The state is a prop or comes from the button around it (aria-pressed, aria-expanded, data-state="done", data-on). Zero JavaScript.',
8
+ summary: 'Six line icons that morph to a second state, such as menu to close, copy to check and sun to moon.',
9
+ category: 'ui',
10
+ tier: 'A',
11
+ trigger: ['click'],
12
+ support: { baseline: 'limited', needs: ['d-path'], fallback: 'play ↔ pause cross-fades instead of morphing where d: path() is not a CSS property (Safari)' },
13
+ readout: '<AnimatedIcons icon="menu">',
14
+ usage: `<button type="button" aria-expanded="false" aria-label="Menu" onclick="this.ariaExpanded = this.ariaExpanded !== 'true'">\n <AnimatedIcons icon="menu" />\n</button>\n<AnimatedIcons icon="play" state="on" size="1.5em" />`,
15
+ props: [
16
+ { name: 'icon', type: "'menu' | 'copy' | 'play' | 'plus' | 'check' | 'sun'", description: 'Which pair.' },
17
+ { name: 'state', type: "'on' | 'off'", description: 'Second state; on or off pins it. Unset, the icon is on while any ancestor has aria-pressed="true", aria-expanded="true", data-state="done" or data-on, however far out: an open menu around a button counts too, so pass off to keep an icon out of it.' },
18
+ { name: 'size', type: 'string', default: "'1em'", description: 'Any CSS length; follows the button\'s font-size by default.' },
19
+ { name: 'stroke', type: 'number', default: '2', description: 'Stroke width on the 24-unit grid.' },
20
+ ],
21
+ reducedMotion: 'The strokes switch at once.',
22
+ clientRouter: 'CSS-only; the state is on the element or its button and goes with the page.',
23
+ craft: [
24
+ 'The state is read from the button\'s own ARIA, so the attribute a screen reader needs is the one that moves the icon. There is no second source of truth.',
25
+ 'Individual transform properties per stroke with transform-box: fill-box: the burger\'s outer lines slide to the middle and turn 45°, the middle line collapses; nothing is a path morph.',
26
+ 'Play ↔ pause morphs its two halves with d: path() where it can (same command list on both ends), and cross-fades where it cannot, so the fallback is a clean state change, not a broken morph.',
27
+ 'Copy → check draws the check after the rectangles have shrunk away (a third of the duration later), so the two do not overlap mid-flight.',
28
+ 'Everything sits on the preset spring; a snap preset makes the burger click, ambient makes it breathe.',
29
+ ],
30
+ related: ['copy-button', 'theme-toggle', 'stateful-button'],
31
+ intents: ['animated hamburger icon', 'menu to x icon animation', 'play pause morph icon', 'copy to check icon', 'sun moon icon animation', 'animated icons css'],
32
+ replaces: ['AnimatedIcons (Skiper UI)', 'icon morph (Motion Primitives)', 'lucide-react animated icons'],
33
+ fixture: { props: { icon: 'menu' }, expect: 'data-icon="menu"' },
34
+ } satisfies ComponentMeta;
@@ -0,0 +1,305 @@
1
+ ---
2
+ /**
3
+ * AnimatedList — notifications arriving one at a time, the older ones
4
+ * pushed down and the oldest pushed out, zero JS.
5
+ *
6
+ * Two modes. `interval` is a pure-CSS cycle for demos and landing pages:
7
+ * one registered number runs 0 → 1 over the whole period on every item,
8
+ * phased by index; the item's time slot k = floor(t × n) says whether it is
9
+ * visible (k < max), leaving (k = max) or parked above the list waiting to
10
+ * re-enter, and its translate, opacity and scale are calc() on k — every
11
+ * change of k is a CSS transition on the preset curve, so the arrival, the
12
+ * push and the exit are all the same curve, and no keyframe knows how many
13
+ * items there are. A list too short to cycle (fewer than max + 2 items)
14
+ * stays still. Live mode is the host's: prepend an item (newest first) and
15
+ * it grows in through @starting-style while the item pushed past `max`
16
+ * leaves through allow-discrete; an item appended to a full list is past
17
+ * `max` itself and never shows. Reduced motion shows the first `max`
18
+ * items, still.
19
+ */
20
+ import type { HTMLAttributes } from 'astro/types';
21
+
22
+ interface Item {
23
+ title: string;
24
+ body?: string;
25
+ time?: string;
26
+ /** Inline SVG or any HTML for the badge; default the first letter of the title. */
27
+ icon?: string;
28
+ /** Badge colour. */
29
+ color?: string;
30
+ }
31
+
32
+ interface Props extends HTMLAttributes<'ul'> {
33
+ /** Items rendered as cards. Or slot your own <li> children (each with one child element). */
34
+ items?: Item[];
35
+ /** Cycle on its own: the slot each item gets, as a duration token. */
36
+ interval?: 'fast' | 'base' | 'slow';
37
+ /** Items visible at once (2–12). */
38
+ max?: number;
39
+ /** Row height in interval mode (every item is this tall). */
40
+ row?: string;
41
+ /** Gap between rows. */
42
+ gap?: string;
43
+ /** Announce additions: role="log" + aria-live="polite" (live mode). */
44
+ live?: boolean;
45
+ }
46
+
47
+ const { items, interval, max = 4, row = '3.5rem', gap = '0.5rem', live = false, class: className, style, ...rest } = Astro.props;
48
+ const n = items?.length;
49
+ // the cycle needs one exit slot and one parked slot after the visible ones
50
+ const shown = Math.max(2, Math.min(12, n ? Math.min(max, n - 2) : max));
51
+ const inline = [`--ma-alist-max:${shown}`, `--ma-alist-row:${row}`, `--ma-alist-gap:${gap}`, n && `--ma-alist-n:${n}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
52
+ const glyph = (t: string) => `<b>${t.trim().charAt(0).toUpperCase()}</b>`;
53
+ ---
54
+
55
+ <ul
56
+ class:list={['ma-alist', className]}
57
+ data-interval={interval}
58
+ data-max={shown}
59
+ role={live && !interval ? 'log' : undefined}
60
+ aria-live={live && !interval ? 'polite' : undefined}
61
+ style={inline}
62
+ {...rest}
63
+ >
64
+ {
65
+ items ? (
66
+ items.map((it: Item) => (
67
+ <li class="ma-alist__item">
68
+ <div class="ma-alist__card" style={it.color ? `--ma-alist-color:${it.color}` : undefined}>
69
+ <span class="ma-alist__badge" aria-hidden="true" set:html={it.icon ?? glyph(it.title)} />
70
+ <span class="ma-alist__text">
71
+ <span class="ma-alist__title">{it.title}</span>
72
+ {it.body && <span class="ma-alist__body">{it.body}</span>}
73
+ </span>
74
+ {it.time && <time class="ma-alist__time">{it.time}</time>}
75
+ </div>
76
+ </li>
77
+ ))
78
+ ) : (
79
+ <slot />
80
+ )
81
+ }
82
+ </ul>
83
+
84
+ <style is:global>
85
+ @property --ma-alist-t {
86
+ syntax: '<number>';
87
+ inherits: false;
88
+ initial-value: 0;
89
+ }
90
+
91
+ @layer components {
92
+ :where(.ma-alist) {
93
+ display: grid;
94
+ gap: var(--ma-alist-gap, 0.5rem);
95
+ margin: 0;
96
+ padding: 0;
97
+ list-style: none;
98
+ }
99
+ /* index and count, from position — so slotted children work the same as items. The index carries the list's own
100
+ name: it inherits into the cards, and a component inside one would read a shared --ma-i as its own index */
101
+ :where(.ma-alist > :nth-child(1)) { --ma-alist-i: 0; }
102
+ :where(.ma-alist > :nth-child(2)) { --ma-alist-i: 1; }
103
+ :where(.ma-alist > :nth-child(3)) { --ma-alist-i: 2; }
104
+ :where(.ma-alist > :nth-child(4)) { --ma-alist-i: 3; }
105
+ :where(.ma-alist > :nth-child(5)) { --ma-alist-i: 4; }
106
+ :where(.ma-alist > :nth-child(6)) { --ma-alist-i: 5; }
107
+ :where(.ma-alist > :nth-child(7)) { --ma-alist-i: 6; }
108
+ :where(.ma-alist > :nth-child(8)) { --ma-alist-i: 7; }
109
+ :where(.ma-alist > :nth-child(9)) { --ma-alist-i: 8; }
110
+ :where(.ma-alist > :nth-child(10)) { --ma-alist-i: 9; }
111
+ :where(.ma-alist > :nth-child(11)) { --ma-alist-i: 10; }
112
+ :where(.ma-alist > :nth-child(12)) { --ma-alist-i: 11; }
113
+ :where(.ma-alist:has(> :nth-child(1):last-child)) { --ma-alist-n: 1; }
114
+ :where(.ma-alist:has(> :nth-child(2):last-child)) { --ma-alist-n: 2; }
115
+ :where(.ma-alist:has(> :nth-child(3):last-child)) { --ma-alist-n: 3; }
116
+ :where(.ma-alist:has(> :nth-child(4):last-child)) { --ma-alist-n: 4; }
117
+ :where(.ma-alist:has(> :nth-child(5):last-child)) { --ma-alist-n: 5; }
118
+ :where(.ma-alist:has(> :nth-child(6):last-child)) { --ma-alist-n: 6; }
119
+ :where(.ma-alist:has(> :nth-child(7):last-child)) { --ma-alist-n: 7; }
120
+ :where(.ma-alist:has(> :nth-child(8):last-child)) { --ma-alist-n: 8; }
121
+ :where(.ma-alist:has(> :nth-child(9):last-child)) { --ma-alist-n: 9; }
122
+ :where(.ma-alist:has(> :nth-child(10):last-child)) { --ma-alist-n: 10; }
123
+ :where(.ma-alist:has(> :nth-child(11):last-child)) { --ma-alist-n: 11; }
124
+ :where(.ma-alist:has(> :nth-child(12):last-child)) { --ma-alist-n: 12; }
125
+ /* live mode: an item is a grid row that grows in; past max it leaves */
126
+ :where(.ma-alist > *) {
127
+ display: grid;
128
+ grid-template-rows: 1fr;
129
+ opacity: 1;
130
+ translate: 0 0;
131
+ scale: 1;
132
+ transition:
133
+ grid-template-rows var(--ma-duration) var(--ma-ease),
134
+ opacity var(--ma-duration) var(--ma-ease-out),
135
+ translate var(--ma-duration) var(--ma-ease),
136
+ scale var(--ma-duration) var(--ma-ease),
137
+ display var(--ma-duration) allow-discrete;
138
+ }
139
+ :where(.ma-alist > * > *) {
140
+ min-height: 0;
141
+ overflow: clip;
142
+ }
143
+ @starting-style {
144
+ :where(.ma-alist:not([data-interval]) > *) {
145
+ grid-template-rows: 0fr;
146
+ opacity: 0;
147
+ translate: 0 calc(var(--ma-travel-enter, 24px) * -1);
148
+ scale: 0.96;
149
+ }
150
+ }
151
+ :where(.ma-alist[data-max='2']:not([data-interval]) > :nth-child(n + 3)),
152
+ :where(.ma-alist[data-max='3']:not([data-interval]) > :nth-child(n + 4)),
153
+ :where(.ma-alist[data-max='4']:not([data-interval]) > :nth-child(n + 5)),
154
+ :where(.ma-alist[data-max='5']:not([data-interval]) > :nth-child(n + 6)),
155
+ :where(.ma-alist[data-max='6']:not([data-interval]) > :nth-child(n + 7)),
156
+ :where(.ma-alist[data-max='7']:not([data-interval]) > :nth-child(n + 8)),
157
+ :where(.ma-alist[data-max='8']:not([data-interval]) > :nth-child(n + 9)),
158
+ :where(.ma-alist[data-max='9']:not([data-interval]) > :nth-child(n + 10)),
159
+ :where(.ma-alist[data-max='10']:not([data-interval]) > :nth-child(n + 11)),
160
+ :where(.ma-alist[data-max='11']:not([data-interval]) > :nth-child(n + 12)),
161
+ :where(.ma-alist[data-max='12']:not([data-interval]) > :nth-child(n + 13)) {
162
+ display: none;
163
+ grid-template-rows: 0fr;
164
+ opacity: 0;
165
+ scale: 0.96;
166
+ }
167
+ /* interval mode: a fixed box of max rows; items are laid on top of each other and placed by their time slot */
168
+ :where(.ma-alist[data-interval]) {
169
+ --ma-alist-slot: var(--ma-dur-ambient);
170
+ --ma-alist-period: calc(var(--ma-alist-n, 4) * var(--ma-alist-slot));
171
+ position: relative;
172
+ display: block;
173
+ height: calc(var(--ma-alist-max, 4) * (var(--ma-alist-row, 3.5rem) + var(--ma-alist-gap, 0.5rem)) - var(--ma-alist-gap, 0.5rem));
174
+ overflow: clip;
175
+ }
176
+ :where(.ma-alist[data-interval='fast']) {
177
+ --ma-alist-slot: var(--ma-dur-gentle);
178
+ }
179
+ :where(.ma-alist[data-interval='slow']) {
180
+ --ma-alist-slot: calc(var(--ma-dur-ambient) * 2);
181
+ }
182
+ :where(.ma-alist[data-interval] > *) {
183
+ /* ok: 1 when the list can cycle (n ≥ max + 2: one slot to leave, one to hide), else 0 and every item keeps k = its index, still */
184
+ --ma-alist-ok: clamp(0, var(--ma-alist-n, 4) - var(--ma-alist-max, 4) - 1, 1);
185
+ /* k: this item's slot right now — 0 is the newest row, max is on its way out, more is parked above waiting to come back */
186
+ --ma-alist-k: calc(var(--ma-alist-ok) * round(down, var(--ma-alist-t) * var(--ma-alist-n, 4)) + (1 - var(--ma-alist-ok)) * var(--ma-alist-i, 0));
187
+ --ma-alist-v: clamp(0, var(--ma-alist-max, 4) - var(--ma-alist-k), 1);
188
+ --ma-alist-e: clamp(0, 1 - (var(--ma-alist-k) - var(--ma-alist-max, 4)) * (var(--ma-alist-k) - var(--ma-alist-max, 4)), 1);
189
+ --ma-alist-p: calc(1 - var(--ma-alist-v) - var(--ma-alist-e));
190
+ position: absolute;
191
+ inset: 0 0 auto 0;
192
+ display: block;
193
+ height: var(--ma-alist-row, 3.5rem);
194
+ translate: 0
195
+ calc(
196
+ (var(--ma-alist-v) * var(--ma-alist-k) + var(--ma-alist-e) * var(--ma-alist-max, 4)) * (var(--ma-alist-row, 3.5rem) + var(--ma-alist-gap, 0.5rem)) - var(--ma-alist-p) *
197
+ var(--ma-travel-enter, 24px)
198
+ );
199
+ opacity: var(--ma-alist-v);
200
+ scale: calc(1 - 0.06 * (1 - var(--ma-alist-v)));
201
+ animation: ma-alist-t var(--ma-alist-period) linear infinite;
202
+ animation-delay: calc((var(--ma-alist-n, 4) - var(--ma-alist-i, 0)) * var(--ma-alist-slot) * -1);
203
+ }
204
+ :where(.ma-alist[data-interval] > * > *) {
205
+ height: 100%;
206
+ }
207
+ /* the card */
208
+ :where(.ma-alist__card) {
209
+ display: flex;
210
+ align-items: center;
211
+ gap: 0.75rem;
212
+ box-sizing: border-box;
213
+ padding: 0.625rem 0.875rem;
214
+ border: 1px solid var(--ma-edge);
215
+ border-radius: 0.875rem;
216
+ background: var(--ma-panel);
217
+ box-shadow: 0 10px 28px -20px rgb(0 0 0 / 0.4);
218
+ }
219
+ :where(.ma-alist__badge) {
220
+ display: grid;
221
+ flex: none;
222
+ place-items: center;
223
+ width: 2.25rem;
224
+ height: 2.25rem;
225
+ border-radius: 0.625rem;
226
+ background: var(--ma-alist-color, var(--ma-ink));
227
+ color: var(--ma-panel);
228
+ font-size: 0.9375rem;
229
+ font-weight: 600;
230
+ line-height: 1;
231
+ }
232
+ :where(.ma-alist__badge svg) {
233
+ width: 55%;
234
+ height: 55%;
235
+ }
236
+ :where(.ma-alist__text) {
237
+ display: grid;
238
+ flex: 1;
239
+ min-width: 0;
240
+ gap: 0.1rem;
241
+ }
242
+ :where(.ma-alist__title) {
243
+ font-size: 0.875rem;
244
+ font-weight: 600;
245
+ line-height: 1.25;
246
+ white-space: nowrap;
247
+ overflow: hidden;
248
+ text-overflow: ellipsis;
249
+ }
250
+ :where(.ma-alist__body) {
251
+ font-size: 0.8125rem;
252
+ line-height: 1.3;
253
+ opacity: 0.7;
254
+ white-space: nowrap;
255
+ overflow: hidden;
256
+ text-overflow: ellipsis;
257
+ }
258
+ :where(.ma-alist__time) {
259
+ flex: none;
260
+ align-self: flex-start;
261
+ font-family: ui-monospace, monospace;
262
+ font-size: 0.6875rem;
263
+ opacity: var(--ma-dim, 0.7);
264
+ }
265
+ /* reduced motion: the first max items, still */
266
+ @media (prefers-reduced-motion: reduce) {
267
+ :where(.ma-alist > *) {
268
+ transition: none;
269
+ }
270
+ :where(.ma-alist[data-interval]) {
271
+ display: grid;
272
+ height: auto;
273
+ }
274
+ :where(.ma-alist[data-interval] > *) {
275
+ position: static;
276
+ translate: 0 0;
277
+ opacity: 1;
278
+ scale: 1;
279
+ animation: none;
280
+ }
281
+ :where(.ma-alist[data-max='2'][data-interval] > :nth-child(n + 3)),
282
+ :where(.ma-alist[data-max='3'][data-interval] > :nth-child(n + 4)),
283
+ :where(.ma-alist[data-max='4'][data-interval] > :nth-child(n + 5)),
284
+ :where(.ma-alist[data-max='5'][data-interval] > :nth-child(n + 6)),
285
+ :where(.ma-alist[data-max='6'][data-interval] > :nth-child(n + 7)),
286
+ :where(.ma-alist[data-max='7'][data-interval] > :nth-child(n + 8)),
287
+ :where(.ma-alist[data-max='8'][data-interval] > :nth-child(n + 9)),
288
+ :where(.ma-alist[data-max='9'][data-interval] > :nth-child(n + 10)),
289
+ :where(.ma-alist[data-max='10'][data-interval] > :nth-child(n + 11)),
290
+ :where(.ma-alist[data-max='11'][data-interval] > :nth-child(n + 12)),
291
+ :where(.ma-alist[data-max='12'][data-interval] > :nth-child(n + 13)) {
292
+ display: none;
293
+ }
294
+ }
295
+ }
296
+
297
+ @keyframes ma-alist-t {
298
+ from {
299
+ --ma-alist-t: 0;
300
+ }
301
+ to {
302
+ --ma-alist-t: 1;
303
+ }
304
+ }
305
+ </style>
@@ -0,0 +1,36 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'AnimatedList',
5
+ slug: 'animated-list',
6
+ title: 'Animated List',
7
+ description: 'Shows notifications arriving one at a time: the new one drops in at the top, the older ones are pushed down, and the oldest slides out. Zero JavaScript: in interval mode one registered number runs over the period on every item and the item\'s time slot decides where it is. Every change is a transition on the preset curve, and no keyframe knows how many items there are. In live mode the host prepends items (newest first) and they grow in through @starting-style while the oldest leaves.',
8
+ summary: 'A notification list where each new item drops in at the top, the older ones move down and the oldest slides out.',
9
+ category: 'ui',
10
+ tier: 'A',
11
+ trigger: ['always', 'load'],
12
+ support: { baseline: 'newly', needs: ['property', 'css-math', 'has', 'starting-style', 'allow-discrete', 'grid-rows-animation'], fallback: 'without round() or registered properties the interval mode shows the first items still; live items appear at once' },
13
+ readout: '<AnimatedList interval="base" max={4}>',
14
+ usage: `<AnimatedList\n interval="base"\n max={4}\n items={[\n { title: 'Deployed', body: 'main → production in 41 s', time: 'now', color: 'var(--cat-green)' },\n { title: 'New signup', body: 'ari@studio.example', time: '1m' },\n { title: 'Payment received', body: '$120 · Plus, yearly', time: '2m' },\n { title: 'Comment', body: '"looks great on mobile"', time: '3m' },\n { title: 'Build passed', body: '1 076 checks', time: '5m' },\n { title: 'Star', body: 'someone starred the repo', time: '6m' },\n ]}\n/>\n\n<!-- live: prepend each new <li> (one child element) yourself, newest first: feed.prepend(li); role="log" announces it -->\n<AnimatedList live max={5} id="feed" />`,
15
+ props: [
16
+ { name: 'items', type: '{ title: string; body?: string; time?: string; icon?: string; color?: string }[]', description: 'Cards. Or slot your own <li> children, each with one child element.' },
17
+ { name: 'interval', type: "'fast' | 'base' | 'slow'", description: 'Cycle on its own; the slot each item gets is the gentle, ambient or twice-ambient duration token. Needs at least max + 2 items: with items, max is lowered to fit; a list that still has too few (fewer than four items, or slotted children under max + 2) stays still.' },
18
+ { name: 'max', type: 'number', default: '4', description: 'Items visible at once, 2–12.' },
19
+ { name: 'row', type: 'string', default: "'3.5rem'", description: 'Row height in interval mode; every item is this tall.' },
20
+ { name: 'gap', type: 'string', default: "'0.5rem'", description: 'Space between rows.' },
21
+ { name: 'live', type: 'boolean', default: 'false', description: 'In live mode, role="log" and aria-live="polite" so added items are announced. Prepend them: an item appended to a full list is past max and never shows.' },
22
+ ],
23
+ reducedMotion: 'The first max items, still, in a plain column; live additions appear without motion.',
24
+ clientRouter: 'CSS-only; nothing to rebind. The cycle restarts with the page.',
25
+ craft: [
26
+ 'The cycle is one registered <number> animated 0 → 1 with literal keyframes (Firefox will not interpolate a var() end value) over n × slot, phased per item with a negative delay of (n − index) × slot, so the items arrive in source order.',
27
+ 'The item\'s slot k = round(down, t × n) is the only state. Visible while k < max, leaving at k = max, parked above the list after that. These are three numbers from clamp() and a square, with no abs() needed, and translate, opacity and scale are calc() on them.',
28
+ 'Every change of k is a plain CSS transition on the preset curve: the arrival from above, each push down a row and the exit below the box are the same motion, and the parked → 0 jump happens at opacity 0 where no one sees it. That is why the list needs max + 2 items: one slot to leave, one to hide.',
29
+ 'Rows are absolutely positioned at a fixed height so translate is a length the CSS can compute; the box is max rows tall and clips. A notification card is the same height every time, so the constraint costs nothing.',
30
+ 'Live mode is the Accordion trick per item (a grid row from 0fr to 1fr through @starting-style), so a prepended item pushes the others down on the same curve instead of jumping the layout, and the one pushed past max leaves through allow-discrete. Visibility is by position (the first max children), so the newest goes first: appended to a full list, it would be past max itself.',
31
+ ],
32
+ related: ['toast', 'reveal', 'marquee'],
33
+ intents: ['animated notification list', 'notifications one by one', 'activity feed animation', 'list items slide in stack', 'animated list astro', 'chat messages arriving in sequence'],
34
+ replaces: ['AnimatedList (Magic UI)', 'AnimatedList (React Bits)', 'AnimatePresence list (Framer Motion)'],
35
+ fixture: { props: { interval: 'base', max: 2, items: [{ title: 'Fixture content for AnimatedList' }, { title: 'Two' }, { title: 'Three' }, { title: 'Four' }] }, expect: 'Fixture content for AnimatedList' },
36
+ } satisfies ComponentMeta;