@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,68 @@
1
+ ---
2
+ /**
3
+ * HighlightMarker — a highlighter sweeps across phrases, one after another,
4
+ * when the text scrolls into view. Phrases are marked **like this** in the
5
+ * prop and rendered as <mark> elements whose background-size grows from 0
6
+ * to 100% on a per-phrase delay; box-decoration-break keeps the sweep on
7
+ * wrapped lines. Composes Reveal; zero script of its own. The server HTML
8
+ * is the plain sentence with the marks already in place.
9
+ */
10
+ import type { HTMLAttributes, HTMLTag } from 'astro/types';
11
+ import Reveal from './Reveal.astro';
12
+
13
+ interface Props extends HTMLAttributes<'p'> {
14
+ /** The sentence; wrap phrases to highlight in double asterisks. */
15
+ text: string;
16
+ /** Element to render. */
17
+ as?: HTMLTag;
18
+ /** Highlight colour; a translucent colour keeps the text legible. */
19
+ color?: string;
20
+ /** Sweep duration per phrase in ms. Unset: the preset's --ma-duration. */
21
+ duration?: number;
22
+ /** Delay in ms before the first sweep. */
23
+ delay?: number;
24
+ /** Play once and stay. */
25
+ once?: boolean;
26
+ /** Fraction visible to trigger. */
27
+ threshold?: number;
28
+ }
29
+
30
+ const { text, as = 'p', color = 'color-mix(in oklch, currentColor 22%, transparent)', duration, delay, once, threshold, class: className, style, ...rest } = Astro.props;
31
+ const parts = text.split(/\*\*([^*]+)\*\*/);
32
+ let mark = 0;
33
+ const vars = [`--ma-marker-color:${color}`, duration !== undefined && `--ma-marker-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
34
+ ---
35
+
36
+ <Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-marker', className].filter(Boolean).join(' ')} style={vars} {...rest}>
37
+ {parts.map((part, i) => (i % 2 ? <mark class="ma-marker__mark" style={`--ma-marker-i:${mark++}`}>{part}</mark> : part))}
38
+ </Reveal>
39
+
40
+ <style is:global>
41
+ @layer components {
42
+ :where(.ma-marker__mark) {
43
+ color: inherit;
44
+ background-color: transparent;
45
+ background-image: linear-gradient(var(--ma-marker-color), var(--ma-marker-color));
46
+ background-repeat: no-repeat;
47
+ background-position: 0 0;
48
+ background-size: 100% 100%;
49
+ padding: 0.08em 0.18em;
50
+ margin-inline: -0.18em;
51
+ border-radius: 0.25em;
52
+ -webkit-box-decoration-break: clone;
53
+ box-decoration-break: clone;
54
+ /* each phrase waits for the one before it; the delay is the one Reveal writes on this root, so the sweep keeps
55
+ step with the sentence's fade */
56
+ transition: background-size var(--ma-marker-dur, var(--ma-duration)) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-marker-i, 0) * var(--ma-marker-dur, var(--ma-duration)));
57
+ }
58
+ :where([data-ma-js] .ma-marker:not(.ma-in) .ma-marker__mark) {
59
+ background-size: 0% 100%;
60
+ transition: none;
61
+ }
62
+ @media (prefers-reduced-motion: reduce) {
63
+ :where(.ma-marker__mark) {
64
+ transition: none;
65
+ }
66
+ }
67
+ }
68
+ </style>
@@ -0,0 +1,37 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'HighlightMarker',
5
+ slug: 'highlight-marker',
6
+ title: 'Highlight Marker',
7
+ description:
8
+ 'A highlighter sweeps across the phrases you marked, one after another, when the sentence scrolls into view. Each phrase is a real <mark> whose background grows from the left; wrapped lines are painted as separate strokes. Composes Reveal; no script of its own.',
9
+ summary: 'A highlighter that sweeps across the phrases you marked, one after another, as the sentence scrolls into view.',
10
+ category: 'text',
11
+ tier: 'A',
12
+ trigger: ['scroll'],
13
+ support: { baseline: 'widely', needs: [] },
14
+ readout: '<HighlightMarker text="…**two phrases**…">',
15
+ usage: `<HighlightMarker text="Every cost here is **measured from a real build** and printed **beside the component**." />\n<HighlightMarker as="h2" text="**Zero bytes** of JavaScript." color="color-mix(in oklch, var(--ma-shine) 60%, transparent)" duration={600} />`,
16
+ props: [
17
+ { name: 'text', type: 'string', description: 'The sentence. Wrap each phrase to highlight in double asterisks; they sweep in order.' },
18
+ { name: 'as', type: 'HTMLTag', default: "'p'", description: 'Element to render.' },
19
+ { name: 'color', type: 'string', default: 'currentColor at 22%', description: 'Highlight colour. Keep it translucent so the text stays legible in both themes.' },
20
+ { name: 'duration', type: 'number', default: 'preset (ui: 450)', description: 'Sweep duration per phrase in ms; the next phrase starts when the previous lands.' },
21
+ { name: 'delay', type: 'number', default: '0', description: 'Delay before the first sweep.' },
22
+ { name: 'once', type: 'boolean', default: 'true', description: 'Play once and stay.' },
23
+ { name: 'threshold', type: 'number', default: '0.2', description: 'Fraction visible to trigger.' },
24
+ ],
25
+ reducedMotion: 'The phrases are highlighted at once.',
26
+ clientRouter: 'Inherits Reveal: rebinds after every navigation.',
27
+ craft: [
28
+ 'background-size on a solid gradient, not a pseudo-element: the sweep follows the text\'s own line boxes, and box-decoration-break: clone gives every wrapped line its own stroke start.',
29
+ 'Sequential: phrase n starts when phrase n − 1 lands, because a highlighter is one hand.',
30
+ 'Ease-out: a marker moves fast and stops without accelerating into the last word.',
31
+ '<mark> is the semantic element for a highlight, so the emphasis is in the accessibility tree and the HTML is readable without any CSS.',
32
+ ],
33
+ related: ['split-text', 'underline-hover', 'shiny-text'],
34
+ intents: ['highlight text on scroll', 'marker highlight animation', 'highlighter sweep text', 'animated text highlight', 'mark animation css'],
35
+ replaces: ['Highlighter (Magic UI)', 'Highlight (Aceternity hero highlight)'],
36
+ fixture: { props: { text: 'Fixture content for **HighlightMarker**' }, expect: 'Fixture content for <mark' },
37
+ } satisfies ComponentMeta;
@@ -0,0 +1,112 @@
1
+ ---
2
+ /**
3
+ * HorizontalScroll — a strip that moves sideways as the page scrolls down.
4
+ *
5
+ * The section is tall; inside it a sticky viewport holds a max-content
6
+ * track whose translate is bound to the section's view timeline, so the
7
+ * track crosses exactly while the section is pinned. Zero JS. Browsers
8
+ * without scroll timelines get a normal horizontally scrollable strip — a tab
9
+ * stop, so the arrow keys scroll it (reduced motion gets the same strip).
10
+ * Keyboard focus inside the pinned strip turns it into that strip for as long
11
+ * as it stays, so a focused item is scrolled into view by the browser.
12
+ */
13
+ import type { HTMLAttributes } from 'astro/types';
14
+
15
+ interface Props extends HTMLAttributes<'section'> {
16
+ /** Scroll distance as viewport heights: how long the strip stays pinned. */
17
+ length?: number;
18
+ /** Gap between items, any CSS length. */
19
+ gap?: string;
20
+ /** Height of the pinned viewport; 100vh for the page, or the height of an enclosing scroller. */
21
+ viewport?: string;
22
+ /** Accessible name of the strip, which is a tab stop: say what it holds ("Case studies"). */
23
+ label?: string;
24
+ }
25
+
26
+ const { length = 3, gap = '2rem', viewport = '100vh', label = 'Horizontal strip', class: className, style, ...rest } = Astro.props;
27
+ const vars = [`--ma-hs-len:${length}`, `--ma-hs-gap:${gap}`, `--ma-hs-vp:${viewport}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
28
+ ---
29
+
30
+ <section class:list={['ma-hscroll', className]} style={vars} {...rest}>
31
+ <div class="ma-hscroll__viewport" tabindex="0" role="region" aria-label={label}>
32
+ <div class="ma-hscroll__track">
33
+ <slot />
34
+ </div>
35
+ </div>
36
+ </section>
37
+
38
+ <style is:global>
39
+ @layer components {
40
+ :where(.ma-hscroll__viewport) {
41
+ overflow-x: auto;
42
+ container-type: inline-size;
43
+ }
44
+ :where(.ma-hscroll__track) {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: var(--ma-hs-gap, 2rem);
48
+ width: max-content;
49
+ height: 100%;
50
+ }
51
+ @supports (animation-timeline: view()) {
52
+ :where(.ma-hscroll) {
53
+ height: calc(var(--ma-hs-vp, 100vh) * var(--ma-hs-len, 3));
54
+ view-timeline-name: --ma-hs;
55
+ }
56
+ /* clip, not hidden: a hidden box is still a scroll container, so a focused item scrolled it sideways and the offset
57
+ stayed on top of the translate after focus left */
58
+ :where(.ma-hscroll__viewport) {
59
+ position: sticky;
60
+ top: 0;
61
+ height: var(--ma-hs-vp, 100vh);
62
+ overflow: clip;
63
+ }
64
+ :where(.ma-hscroll__track) {
65
+ will-change: translate;
66
+ animation: ma-hscroll linear both;
67
+ animation-range: contain 0% contain 100%;
68
+ }
69
+ :where(html) :where(.ma-hscroll__track) {
70
+ animation-timeline: --ma-hs;
71
+ }
72
+ /* right to left the track starts at the right edge, so it travels right: the same distance, mirrored. [dir] and
73
+ not :dir(): :dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only
74
+ dir never matches. An ltr island one level inside an rtl page is set back */
75
+ :where([dir='rtl'] .ma-hscroll, .ma-hscroll[dir='rtl']) {
76
+ --ma-hs-dir: -1;
77
+ }
78
+ :where([dir='rtl'] [dir='ltr'] .ma-hscroll, [dir='rtl'] .ma-hscroll[dir='ltr']) {
79
+ --ma-hs-dir: 1;
80
+ }
81
+ /* keyboard focus in the strip (the strip itself, or an item) makes it the scrollable strip until focus leaves: the
82
+ browser scrolls the focused item into view, and the offset goes when clip comes back. A click on a link or a
83
+ button does not (a text field matches :focus-visible however it is focused) */
84
+ :where(.ma-hscroll__viewport:is(:focus-visible, :has(:focus-visible))) {
85
+ overflow-x: auto;
86
+ }
87
+ :where(.ma-hscroll__viewport:is(:focus-visible, :has(:focus-visible)) .ma-hscroll__track) {
88
+ animation: none;
89
+ }
90
+ }
91
+ @media (prefers-reduced-motion: reduce) {
92
+ :where(.ma-hscroll) {
93
+ height: auto;
94
+ }
95
+ :where(.ma-hscroll__viewport) {
96
+ position: static;
97
+ height: auto;
98
+ overflow-x: auto;
99
+ }
100
+ :where(.ma-hscroll__track) {
101
+ animation: none;
102
+ }
103
+ }
104
+ }
105
+
106
+ /* min(): a track narrower than the viewport stays put instead of travelling the wrong way */
107
+ @keyframes ma-hscroll {
108
+ to {
109
+ translate: calc(var(--ma-hs-dir, 1) * min(0px, 100cqw - 100%)) 0;
110
+ }
111
+ }
112
+ </style>
@@ -0,0 +1,34 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'HorizontalScroll',
5
+ slug: 'horizontal-scroll',
6
+ title: 'Horizontal Scroll',
7
+ description: 'A strip of items slides sideways while the page scrolls down and the section stays pinned. Zero JavaScript: a sticky viewport and a translate bound to the section\'s view timeline. Where scroll timelines are missing it is a normal horizontally scrollable strip.',
8
+ summary: 'A horizontal scroll section: the strip slides sideways while the page scrolls down and the section stays pinned.',
9
+ category: 'scroll',
10
+ tier: 'A',
11
+ trigger: ['scroll'],
12
+ support: { baseline: 'limited', needs: ['scroll-timeline'], fallback: 'a horizontally scrollable strip without pinning (Firefox)' },
13
+ readout: '<HorizontalScroll length={3}>',
14
+ usage: `<HorizontalScroll length={3} gap="2rem" label="Case studies">\n <article class="w-[70vw]">One</article>\n <article class="w-[70vw]">Two</article>\n <article class="w-[70vw]">Three</article>\n</HorizontalScroll>`,
15
+ props: [
16
+ { name: 'length', type: 'number', default: '3', description: 'How many viewport heights the section stays pinned for. Roughly one per item.' },
17
+ { name: 'gap', type: 'string', default: "'2rem'", description: 'Gap between items.' },
18
+ { name: 'viewport', type: 'string', default: "'100vh'", description: 'Height of the pinned viewport: the page, or an enclosing scroller\'s height.' },
19
+ { name: 'label', type: 'string', default: "'Horizontal strip'", description: 'Accessible name of the strip, which is a tab stop and a region. Say what it holds ("Case studies").' },
20
+ ],
21
+ reducedMotion: 'Not pinned; the strip scrolls horizontally by hand.',
22
+ clientRouter: 'CSS-only; nothing to rebind.',
23
+ craft: [
24
+ 'Where it falls back to a scrollable strip (no scroll timelines, or reduced motion) the viewport is a tab stop, so the strip scrolls from the keyboard too.',
25
+ 'Pinned, the viewport clips (overflow: clip, not hidden, so it is not a scroll container), and keyboard focus inside it turns it into the scrollable strip until focus leaves: the browser scrolls a focused item into view, and nothing stays scrolled on top of the translate afterwards. A click on a link or a button changes nothing; a text field shows focus however it gets it, so clicking one does.',
26
+ 'The travel is min(0px, 100cqw - 100%): the track\'s own width minus the sticky viewport\'s, measured by the browser through a container query unit, with nothing computed in script; a track narrower than the viewport stays put. Right to left (a dir="rtl" on the page or an ancestor) the same distance is mirrored, because the track starts at the right edge.',
27
+ 'animation-range contain 0% → 100%: the strip moves only while the section fully covers the viewport, so it never starts before it is pinned.',
28
+ 'The section\'s height is the scroll budget; length ≈ item count keeps the sideways speed close to the scroll speed.',
29
+ ],
30
+ related: ['card-stack', 'parallax', 'marquee'],
31
+ intents: ['horizontal scroll section', 'scroll sideways on vertical scroll', 'pinned horizontal scroll', 'horizontal scrolling gallery astro'],
32
+ replaces: ['GSAP ScrollTrigger pin + horizontal tween', 'Locomotive horizontal sections'],
33
+ fixture: { slot: '<div>Fixture content for HorizontalScroll</div><div>Two</div>', expect: 'Fixture content for HorizontalScroll' },
34
+ } satisfies ComponentMeta;
@@ -0,0 +1,219 @@
1
+ ---
2
+ /**
3
+ * HoverImageLinks — a list of project links; hovering one floats its image
4
+ * beside the pointer, leaning the way the pointer moves. One floating box
5
+ * per list, not per item: every image is server-rendered in it, lazy, and
6
+ * the hovered item picks which one shows through one number. The pointer
7
+ * writes two lengths and a lean; CSS gives the follow its lag on the fast
8
+ * token and the exit curve (the Cursor pattern), and :has() shows the box
9
+ * while a link is hovered. Keyboard focus shows the image at a fixed spot
10
+ * on the right; so does reduced motion; touch gets the list.
11
+ */
12
+ import type { HTMLAttributes } from 'astro/types';
13
+
14
+ interface Item {
15
+ href: string;
16
+ title: string;
17
+ image: string;
18
+ /** A mono line beside the title (year, category). */
19
+ meta?: string;
20
+ }
21
+
22
+ interface Props extends HTMLAttributes<'div'> {
23
+ items: Item[];
24
+ /** Width of the floating image in px. */
25
+ size?: number;
26
+ /** Maximum lean in degrees. */
27
+ tilt?: number;
28
+ /** Accessible name of the list. */
29
+ label?: string;
30
+ }
31
+
32
+ const { items, size = 220, tilt = 6, label = 'Projects', class: className, style, ...rest } = Astro.props;
33
+ const list = items.slice(0, 12);
34
+ const inline = [`--ma-hil-size:${size}px`, `--ma-hil-tilt:${tilt}deg`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
35
+ ---
36
+
37
+ <div class:list={['ma-hil', className]} data-ma-hil style={inline} {...rest}>
38
+ <ul class="ma-hil__list" aria-label={label}>
39
+ {
40
+ list.map((it, i) => (
41
+ <li class="ma-hil__item">
42
+ <a href={it.href} class="ma-hil__link" data-i={i}>
43
+ <span class="ma-hil__title">{it.title}</span>
44
+ {it.meta && <span class="ma-hil__meta">{it.meta}</span>}
45
+ <svg class="ma-hil__arrow" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M3 13L13 3M6 3h7v7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
46
+ </a>
47
+ </li>
48
+ ))
49
+ }
50
+ </ul>
51
+ <span class="ma-hil__float" aria-hidden="true">
52
+ {list.map((it, i) => <img src={it.image} alt="" loading="lazy" decoding="async" draggable="false" style={`--ma-hil-i:${i}`} />)}
53
+ </span>
54
+ </div>
55
+
56
+ <style is:global>
57
+ @layer components {
58
+ :where(.ma-hil) {
59
+ position: relative;
60
+ }
61
+ :where(.ma-hil__list) {
62
+ margin: 0;
63
+ padding: 0;
64
+ list-style: none;
65
+ }
66
+ :where(.ma-hil__item) {
67
+ border-top: 1px solid var(--ma-edge);
68
+ }
69
+ :where(.ma-hil__item:last-child) {
70
+ border-bottom: 1px solid var(--ma-edge);
71
+ }
72
+ :where(.ma-hil__link) {
73
+ display: flex;
74
+ align-items: baseline;
75
+ gap: 1rem;
76
+ padding: 1.1rem 0;
77
+ color: inherit;
78
+ text-decoration: none;
79
+ transition:
80
+ opacity var(--ma-duration-fast) var(--ma-ease-out),
81
+ translate var(--ma-duration) var(--ma-ease);
82
+ }
83
+ :where(.ma-hil__link:focus-visible) {
84
+ outline: 2px solid currentColor;
85
+ outline-offset: 4px;
86
+ border-radius: 2px;
87
+ }
88
+ :where(.ma-hil__title) {
89
+ flex: 1;
90
+ font-size: clamp(1.5rem, 3vw, 2.25rem);
91
+ font-weight: 600;
92
+ letter-spacing: -0.025em;
93
+ line-height: 1.1;
94
+ }
95
+ :where(.ma-hil__meta) {
96
+ font-family: ui-monospace, monospace;
97
+ font-size: 0.6875rem;
98
+ letter-spacing: 0.08em;
99
+ text-transform: uppercase;
100
+ opacity: var(--ma-dim, 0.7);
101
+ white-space: nowrap;
102
+ }
103
+ :where(.ma-hil__arrow) {
104
+ flex: none;
105
+ align-self: center;
106
+ opacity: 0.4;
107
+ transition:
108
+ translate var(--ma-duration) var(--ma-ease),
109
+ opacity var(--ma-duration-fast) var(--ma-ease-out);
110
+ }
111
+ /* the hovered link steps forward, the rest step back, the arrow points on */
112
+ :where(.ma-hil__list:has(.ma-hil__link:hover) .ma-hil__link:not(:hover)) {
113
+ opacity: 0.4;
114
+ }
115
+ :where(.ma-hil__link:is(:hover, :focus-visible)) {
116
+ translate: 0.5rem 0;
117
+ }
118
+ :where(.ma-hil__link:is(:hover, :focus-visible) .ma-hil__arrow) {
119
+ opacity: 1;
120
+ translate: 0.25rem -0.25rem;
121
+ }
122
+ /* the float: one box, every image stacked in it; the pointer's lengths place it, the lag is CSS */
123
+ :where(.ma-hil__float) {
124
+ position: absolute;
125
+ top: 0;
126
+ left: 0;
127
+ z-index: 1;
128
+ width: var(--ma-hil-size, 220px);
129
+ aspect-ratio: 4 / 3;
130
+ border-radius: 0.75rem;
131
+ overflow: clip;
132
+ background: var(--ma-edge);
133
+ box-shadow: 0 24px 48px -24px rgb(0 0 0 / 0.5);
134
+ pointer-events: none;
135
+ opacity: 0;
136
+ scale: 0.8;
137
+ translate: calc(var(--ma-hil-x, 0px) - 50%) calc(var(--ma-hil-y, 0px) - 50%);
138
+ rotate: calc(var(--ma-hil-r, 0) * var(--ma-hil-tilt, 6deg));
139
+ transition:
140
+ translate var(--ma-duration-fast) var(--ma-ease-out),
141
+ rotate var(--ma-duration) var(--ma-ease),
142
+ opacity var(--ma-duration-fast) var(--ma-ease-out),
143
+ scale var(--ma-duration) var(--ma-ease);
144
+ }
145
+ :where(.ma-hil:has(.ma-hil__link:is(:hover, :focus-visible)) .ma-hil__float) {
146
+ opacity: 1;
147
+ scale: 1;
148
+ }
149
+ :where(.ma-hil__float img) {
150
+ position: absolute;
151
+ inset: 0;
152
+ width: 100%;
153
+ height: 100%;
154
+ object-fit: cover;
155
+ /* 1 for the active index, 0 for every other — a square instead of abs() */
156
+ opacity: clamp(0, 1 - (var(--ma-hil-i, 0) - var(--ma-hil-active, 0)) * (var(--ma-hil-i, 0) - var(--ma-hil-active, 0)), 1);
157
+ transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
158
+ }
159
+ /* keyboard: the image at a fixed spot on the right, no following */
160
+ :where(.ma-hil:has(.ma-hil__link:focus-visible) .ma-hil__float) {
161
+ top: 50%;
162
+ left: auto;
163
+ right: 0;
164
+ translate: 0 -50%;
165
+ rotate: 0deg;
166
+ }
167
+ @media (hover: none), (pointer: coarse) {
168
+ :where(.ma-hil__float) {
169
+ display: none;
170
+ }
171
+ }
172
+ @media (prefers-reduced-motion: reduce) {
173
+ :where(.ma-hil:has(.ma-hil__link:is(:hover, :focus-visible)) .ma-hil__float) {
174
+ top: 50%;
175
+ left: auto;
176
+ right: 0;
177
+ translate: 0 -50%;
178
+ rotate: 0deg;
179
+ scale: 1;
180
+ }
181
+ :where(.ma-hil__float),
182
+ :where(.ma-hil__link),
183
+ :where(.ma-hil__arrow) {
184
+ transition: none;
185
+ }
186
+ }
187
+ }
188
+ </style>
189
+
190
+ <script>
191
+ import { onMount } from '../lib/runtime';
192
+
193
+ onMount<HTMLElement>('[data-ma-hil]', (el, { signal }) => {
194
+ const set = (n: string, v: string) => el.style.setProperty(n, v);
195
+ // which image: the hovered or focused link's index
196
+ const pick = (e: Event) => {
197
+ const a = (e.target as Element).closest<HTMLElement>('.ma-hil__link');
198
+ if (a) set('--ma-hil-active', a.dataset.i!);
199
+ };
200
+ el.addEventListener('pointerover', pick, { signal });
201
+ el.addEventListener('focusin', pick, { signal });
202
+ // bound under reduced motion too: the reduced CSS pins the box to a fixed spot while a link is hovered, so these writes
203
+ // are unseen then, and the follow is already running when the page's switch turns motion back on
204
+ if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
205
+ let lx = 0;
206
+ el.addEventListener(
207
+ 'pointermove',
208
+ (e) => {
209
+ const r = el.getBoundingClientRect();
210
+ set('--ma-hil-x', `${e.clientX - r.left}px`);
211
+ set('--ma-hil-y', `${e.clientY - r.top}px`);
212
+ // the lean is the direction of travel; the curve and the angle are CSS
213
+ set('--ma-hil-r', String(Math.sign(e.clientX - lx)));
214
+ lx = e.clientX;
215
+ },
216
+ { signal, passive: true },
217
+ );
218
+ });
219
+ </script>
@@ -0,0 +1,34 @@
1
+ import type { ComponentMeta } from '../lib/meta';
2
+
3
+ export default {
4
+ name: 'HoverImageLinks',
5
+ slug: 'hover-image-links',
6
+ title: 'Hover Image Links',
7
+ description: 'A list of project links: hovering one floats its image beside the pointer, leaning the way the pointer moves, while the other links step back. One floating box per list with every image server-rendered in it; the script writes two lengths, a lean and an index, and CSS gives the follow its lag on the fast token. Keyboard focus and reduced motion show the image at a fixed spot; touch gets the list.',
8
+ summary: 'A list of links where hovering one floats its image beside the pointer, leaning with the movement.',
9
+ category: 'pointer',
10
+ tier: 'B',
11
+ trigger: ['pointer', 'hover'],
12
+ support: { baseline: 'widely', needs: ['has'], fallback: 'without :has() the box stays hidden' },
13
+ readout: '<HoverImageLinks size={220}>',
14
+ usage: `<HoverImageLinks\n items={[\n { href: '/work/atlas/', title: 'Atlas', meta: 'Identity · 2025', image: '/work/atlas.jpg' },\n { href: '/work/lumen/', title: 'Lumen', meta: 'Web · 2025', image: '/work/lumen.jpg' },\n { href: '/work/field/', title: 'Field notes', meta: 'Editorial · 2024', image: '/work/field.jpg' },\n ]}\n/>`,
15
+ props: [
16
+ { name: 'items', type: '{ href: string; title: string; image: string; meta?: string }[]', description: 'Up to twelve links, each with the image it floats.' },
17
+ { name: 'size', type: 'number', default: '220', description: 'Width of the floating image in px (4:3).' },
18
+ { name: 'tilt', type: 'number', default: '6', description: 'Maximum lean in degrees, in the direction the pointer moves.' },
19
+ { name: 'label', type: 'string', default: "'Projects'", description: 'Accessible name of the list.' },
20
+ ],
21
+ reducedMotion: 'The image appears at a fixed spot on the right of the list while a link is hovered or focused, and nothing follows the pointer. The follow is CSS that the reduced rule overrides, so the page\'s own motion switch applies both ways without a reload.',
22
+ clientRouter: 'Bound through the shared runtime; the listeners are aborted before the swap.',
23
+ craft: [
24
+ 'One box for the whole list, not one per item: every image is in it, stacked and lazy, and the hovered link picks the visible one through a single number. There is no src swap and no image created on hover.',
25
+ 'The follow is the Cursor pattern: the script writes two lengths, translate transitions on the fast token and the exit curve, so the image trails the pointer with the same character as the site\'s cursor.',
26
+ 'The lean is the sign of the pointer\'s horizontal travel times the tilt token, transitioned on the preset curve: three values from the script, one angle from CSS.',
27
+ 'While one link is hovered the others fade to 40 % and the hovered one steps half a rem right with its arrow pointing on: the list itself says which one you are on, before the image does.',
28
+ 'The active image is chosen with clamp() and a square, 1 − (i − active)², so no abs() is needed and the rule works in every engine with clamp() (Chrome 79, Firefox 75, Safari 13.1).',
29
+ ],
30
+ related: ['cursor', 'image-trail', 'underline-hover', 'link-preview'],
31
+ intents: ['hover image links', 'project list hover image follows cursor', 'link list with floating image', 'portfolio index hover preview', 'blog list hover cover', 'image reveal on link hover astro'],
32
+ replaces: ['HoverImageLinks (Hover.dev)', 'LinkPreview list (Skiper UI)', 'FloatingImage (Aceternity)'],
33
+ fixture: { props: { items: [{ href: '/one/', title: 'Fixture content for HoverImageLinks', image: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 4 3%22%3E%3Crect width=%224%22 height=%223%22 fill=%22%23888%22/%3E%3C/svg%3E' }, { href: '/two/', title: 'Two', image: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 4 3%22%3E%3Crect width=%224%22 height=%223%22 fill=%22%23444%22/%3E%3C/svg%3E' }] }, expect: 'Fixture content for HoverImageLinks' },
34
+ } satisfies ComponentMeta;