@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.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/package.json +228 -0
- package/src/components/Accordion.astro +110 -0
- package/src/components/Accordion.meta.ts +33 -0
- package/src/components/AnimatedBeam.astro +217 -0
- package/src/components/AnimatedBeam.meta.ts +39 -0
- package/src/components/AnimatedIcons.astro +193 -0
- package/src/components/AnimatedIcons.meta.ts +34 -0
- package/src/components/AnimatedList.astro +305 -0
- package/src/components/AnimatedList.meta.ts +36 -0
- package/src/components/AnimatedSelect.astro +227 -0
- package/src/components/AnimatedSelect.meta.ts +38 -0
- package/src/components/ArrowButton.astro +133 -0
- package/src/components/ArrowButton.meta.ts +32 -0
- package/src/components/Aurora.astro +188 -0
- package/src/components/Aurora.meta.ts +36 -0
- package/src/components/AvatarStack.astro +205 -0
- package/src/components/AvatarStack.meta.ts +37 -0
- package/src/components/BarGrow.astro +123 -0
- package/src/components/BarGrow.meta.ts +42 -0
- package/src/components/BorderBeam.astro +75 -0
- package/src/components/BorderBeam.meta.ts +33 -0
- package/src/components/BrowserFrame.astro +172 -0
- package/src/components/BrowserFrame.meta.ts +36 -0
- package/src/components/BubbleText.astro +90 -0
- package/src/components/BubbleText.meta.ts +38 -0
- package/src/components/CardStack.astro +79 -0
- package/src/components/CardStack.meta.ts +33 -0
- package/src/components/Carousel.astro +147 -0
- package/src/components/Carousel.meta.ts +35 -0
- package/src/components/CheckDraw.astro +159 -0
- package/src/components/CheckDraw.meta.ts +37 -0
- package/src/components/CircularText.astro +103 -0
- package/src/components/CircularText.meta.ts +38 -0
- package/src/components/CodeTabs.astro +213 -0
- package/src/components/CodeTabs.meta.ts +36 -0
- package/src/components/CommandPalette.astro +363 -0
- package/src/components/CommandPalette.meta.ts +40 -0
- package/src/components/Confetti.astro +143 -0
- package/src/components/Confetti.meta.ts +40 -0
- package/src/components/CopyButton.astro +189 -0
- package/src/components/CopyButton.meta.ts +41 -0
- package/src/components/CountUp.astro +134 -0
- package/src/components/CountUp.meta.ts +40 -0
- package/src/components/CountdownFlip.astro +244 -0
- package/src/components/CountdownFlip.meta.ts +39 -0
- package/src/components/CounterPreloader.astro +122 -0
- package/src/components/CounterPreloader.meta.ts +34 -0
- package/src/components/Cursor.astro +177 -0
- package/src/components/Cursor.meta.ts +37 -0
- package/src/components/CursorGrid.astro +113 -0
- package/src/components/CursorGrid.meta.ts +37 -0
- package/src/components/Curtain.astro +305 -0
- package/src/components/Curtain.meta.ts +38 -0
- package/src/components/Dialog.astro +156 -0
- package/src/components/Dialog.meta.ts +37 -0
- package/src/components/Dock.astro +193 -0
- package/src/components/Dock.meta.ts +35 -0
- package/src/components/DotPattern.astro +58 -0
- package/src/components/DotPattern.meta.ts +32 -0
- package/src/components/DottedMap.astro +153 -0
- package/src/components/DottedMap.meta.ts +38 -0
- package/src/components/Drawer.astro +196 -0
- package/src/components/Drawer.meta.ts +35 -0
- package/src/components/DynamicIsland.astro +122 -0
- package/src/components/DynamicIsland.meta.ts +35 -0
- package/src/components/FileTree.astro +207 -0
- package/src/components/FileTree.meta.ts +34 -0
- package/src/components/FilterGrid.astro +170 -0
- package/src/components/FilterGrid.meta.ts +38 -0
- package/src/components/FooterReveal.astro +54 -0
- package/src/components/FooterReveal.meta.ts +31 -0
- package/src/components/GlitchText.astro +145 -0
- package/src/components/GlitchText.meta.ts +39 -0
- package/src/components/Globe.astro +304 -0
- package/src/components/Globe.meta.ts +41 -0
- package/src/components/GlyphField.astro +363 -0
- package/src/components/GlyphField.meta.ts +53 -0
- package/src/components/GradientText.astro +63 -0
- package/src/components/GradientText.meta.ts +35 -0
- package/src/components/GridPattern.astro +55 -0
- package/src/components/GridPattern.meta.ts +32 -0
- package/src/components/HighlightMarker.astro +68 -0
- package/src/components/HighlightMarker.meta.ts +37 -0
- package/src/components/HorizontalScroll.astro +112 -0
- package/src/components/HorizontalScroll.meta.ts +34 -0
- package/src/components/HoverImageLinks.astro +219 -0
- package/src/components/HoverImageLinks.meta.ts +34 -0
- package/src/components/HoverVideo.astro +179 -0
- package/src/components/HoverVideo.meta.ts +37 -0
- package/src/components/ImageCompare.astro +212 -0
- package/src/components/ImageCompare.meta.ts +39 -0
- package/src/components/ImageTrail.astro +169 -0
- package/src/components/ImageTrail.meta.ts +36 -0
- package/src/components/Lightbox.astro +236 -0
- package/src/components/Lightbox.meta.ts +40 -0
- package/src/components/LinkPreview.astro +234 -0
- package/src/components/LinkPreview.meta.ts +39 -0
- package/src/components/LiquidGlass.astro +148 -0
- package/src/components/LiquidGlass.meta.ts +37 -0
- package/src/components/Magnetic.astro +92 -0
- package/src/components/Magnetic.meta.ts +31 -0
- package/src/components/Marquee.astro +165 -0
- package/src/components/Marquee.meta.ts +38 -0
- package/src/components/Meteors.astro +156 -0
- package/src/components/Meteors.meta.ts +36 -0
- package/src/components/Noise.astro +61 -0
- package/src/components/Noise.meta.ts +33 -0
- package/src/components/NumberFlow.astro +143 -0
- package/src/components/NumberFlow.meta.ts +42 -0
- package/src/components/Orb.astro +130 -0
- package/src/components/Orb.meta.ts +39 -0
- package/src/components/OrbitingCircles.astro +119 -0
- package/src/components/OrbitingCircles.meta.ts +36 -0
- package/src/components/OtpInput.astro +215 -0
- package/src/components/OtpInput.meta.ts +42 -0
- package/src/components/PageTransition.astro +121 -0
- package/src/components/PageTransition.meta.ts +33 -0
- package/src/components/Parallax.astro +69 -0
- package/src/components/Parallax.meta.ts +32 -0
- package/src/components/Particles.astro +210 -0
- package/src/components/Particles.meta.ts +37 -0
- package/src/components/PersistPlayer.astro +126 -0
- package/src/components/PersistPlayer.meta.ts +35 -0
- package/src/components/PhoneFrame.astro +161 -0
- package/src/components/PhoneFrame.meta.ts +35 -0
- package/src/components/PillNav.astro +170 -0
- package/src/components/PillNav.meta.ts +37 -0
- package/src/components/Popover.astro +95 -0
- package/src/components/Popover.meta.ts +34 -0
- package/src/components/PriceSwitch.astro +251 -0
- package/src/components/PriceSwitch.meta.ts +41 -0
- package/src/components/Probe.astro +84 -0
- package/src/components/Progress.astro +165 -0
- package/src/components/Progress.meta.ts +40 -0
- package/src/components/ProgressiveBlur.astro +95 -0
- package/src/components/ProgressiveBlur.meta.ts +36 -0
- package/src/components/PromptInput.astro +273 -0
- package/src/components/PromptInput.meta.ts +46 -0
- package/src/components/ReadingToc.astro +153 -0
- package/src/components/ReadingToc.meta.ts +34 -0
- package/src/components/RetroGrid.astro +84 -0
- package/src/components/RetroGrid.meta.ts +36 -0
- package/src/components/Reveal.astro +292 -0
- package/src/components/Reveal.meta.ts +48 -0
- package/src/components/Ripple.astro +94 -0
- package/src/components/Ripple.meta.ts +34 -0
- package/src/components/RotatingText.astro +170 -0
- package/src/components/RotatingText.meta.ts +35 -0
- package/src/components/ScrollExpand.astro +90 -0
- package/src/components/ScrollExpand.meta.ts +35 -0
- package/src/components/ScrollProgress.astro +73 -0
- package/src/components/ScrollProgress.meta.ts +32 -0
- package/src/components/ScrollScrub.astro +96 -0
- package/src/components/ScrollScrub.meta.ts +37 -0
- package/src/components/ShimmerButton.astro +109 -0
- package/src/components/ShimmerButton.meta.ts +37 -0
- package/src/components/ShinyText.astro +72 -0
- package/src/components/ShinyText.meta.ts +35 -0
- package/src/components/Skeleton.astro +76 -0
- package/src/components/Skeleton.meta.ts +32 -0
- package/src/components/Sparkline.astro +168 -0
- package/src/components/Sparkline.meta.ts +43 -0
- package/src/components/Spinner.astro +79 -0
- package/src/components/Spinner.meta.ts +33 -0
- package/src/components/SplitFlap.astro +157 -0
- package/src/components/SplitFlap.meta.ts +39 -0
- package/src/components/SplitText.astro +208 -0
- package/src/components/SplitText.meta.ts +50 -0
- package/src/components/Spotlight.astro +92 -0
- package/src/components/Spotlight.meta.ts +34 -0
- package/src/components/StaggerMenu.astro +301 -0
- package/src/components/StaggerMenu.meta.ts +39 -0
- package/src/components/StatefulButton.astro +148 -0
- package/src/components/StatefulButton.meta.ts +35 -0
- package/src/components/StickyBanner.astro +168 -0
- package/src/components/StickyBanner.meta.ts +45 -0
- package/src/components/StickyReveal.astro +256 -0
- package/src/components/StickyReveal.meta.ts +33 -0
- package/src/components/StrokeDraw.astro +110 -0
- package/src/components/StrokeDraw.meta.ts +43 -0
- package/src/components/Switch.astro +165 -0
- package/src/components/Switch.meta.ts +37 -0
- package/src/components/Tabs.astro +194 -0
- package/src/components/Tabs.meta.ts +47 -0
- package/src/components/TextRoll.astro +78 -0
- package/src/components/TextRoll.meta.ts +36 -0
- package/src/components/TextScramble.astro +107 -0
- package/src/components/TextScramble.meta.ts +35 -0
- package/src/components/ThemeScript.astro +17 -0
- package/src/components/ThemeToggle.astro +182 -0
- package/src/components/ThemeToggle.meta.ts +34 -0
- package/src/components/Tilt.astro +97 -0
- package/src/components/Tilt.meta.ts +34 -0
- package/src/components/Toast.astro +184 -0
- package/src/components/Toast.meta.ts +33 -0
- package/src/components/TracingBeam.astro +113 -0
- package/src/components/TracingBeam.meta.ts +34 -0
- package/src/components/Typewriter.astro +124 -0
- package/src/components/Typewriter.meta.ts +39 -0
- package/src/components/UnderlineHover.astro +61 -0
- package/src/components/UnderlineHover.meta.ts +34 -0
- package/src/components/VanishInput.astro +240 -0
- package/src/components/VanishInput.meta.ts +41 -0
- package/src/components/VideoDialog.astro +230 -0
- package/src/components/VideoDialog.meta.ts +40 -0
- package/src/components/WeightWave.astro +70 -0
- package/src/components/WeightWave.meta.ts +36 -0
- package/src/components/internal/DialogMorph.astro +97 -0
- package/src/components/internal/PromptInputSubmitOnEnter.astro +32 -0
- package/src/index.d.ts +43 -0
- package/src/index.js +60 -0
- package/src/lib/canvas.ts +124 -0
- package/src/lib/confetti.ts +127 -0
- package/src/lib/contrast.ts +116 -0
- package/src/lib/form.ts +231 -0
- package/src/lib/geo.ts +77 -0
- package/src/lib/gsap.ts +58 -0
- package/src/lib/land.ts +11 -0
- package/src/lib/lenis.ts +112 -0
- package/src/lib/meta.ts +79 -0
- package/src/lib/morph.ts +74 -0
- package/src/lib/reduced-motion-plugin.js +299 -0
- package/src/lib/runtime.ts +215 -0
- package/src/lib/support.ts +102 -0
- package/src/lib/theme.ts +18 -0
- package/src/lib/toast.ts +17 -0
- package/src/styles/base.css +218 -0
- package/src/styles/motion.css +43 -0
- package/src/styles/theme.css +397 -0
- package/src/styles/theme.tailwind.css +75 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Reveal — scroll-triggered entrance that survives <ClientRouter />.
|
|
4
|
+
*
|
|
5
|
+
* Play once and stay (or replay), per-child stagger, scroll-linked scrub with
|
|
6
|
+
* a real fallback, a reduced-motion branch that keeps the fade and drops the
|
|
7
|
+
* travel, and server HTML that is fully readable when JS never runs.
|
|
8
|
+
*
|
|
9
|
+
* Content is hidden only under html[data-ma-js] (set before paint by the
|
|
10
|
+
* integration), never in the server HTML itself.
|
|
11
|
+
*
|
|
12
|
+
* Curve, duration, travel and stagger default to the active preset
|
|
13
|
+
* (--ma-ease, --ma-duration, --ma-travel-enter, --ma-stagger); a prop sets a
|
|
14
|
+
* component-local property (--ma-r-*) that wins over the preset. The delay
|
|
15
|
+
* and the per-child index are --ma-r-delay and --ma-r-i: as bare --ma-delay
|
|
16
|
+
* and --ma-i they were inherited from any other component that set them.
|
|
17
|
+
*/
|
|
18
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
19
|
+
|
|
20
|
+
type From = 'bottom' | 'top' | 'left' | 'right' | 'clip' | 'none';
|
|
21
|
+
|
|
22
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
23
|
+
/** Element to render. */
|
|
24
|
+
as?: HTMLTag;
|
|
25
|
+
/** Where the element travels from; `clip` wipes it open from the bottom edge, fully painted; `none` fades only. */
|
|
26
|
+
from?: From;
|
|
27
|
+
/** Travel distance in px. Unset, the preset's --ma-travel-enter, from whichever side. */
|
|
28
|
+
distance?: number;
|
|
29
|
+
/** Scale to grow from, e.g. 0.96. */
|
|
30
|
+
scale?: number;
|
|
31
|
+
/** Blur in px to sharpen from. */
|
|
32
|
+
blur?: number;
|
|
33
|
+
/** Duration in ms. */
|
|
34
|
+
duration?: number;
|
|
35
|
+
/** Delay in ms before the reveal starts. */
|
|
36
|
+
delay?: number;
|
|
37
|
+
/** Any CSS easing. */
|
|
38
|
+
easing?: string;
|
|
39
|
+
/** Interval in ms between direct children; `true` takes the preset's --ma-stagger. When set, the children animate and the wrapper does not. */
|
|
40
|
+
stagger?: number | boolean;
|
|
41
|
+
/** Play once and stay. `false` re-plays every time the element enters. */
|
|
42
|
+
once?: boolean;
|
|
43
|
+
/** Fraction of the element that must be visible to trigger, 0–1; for an element taller than the viewport, that fraction of the viewport's height. */
|
|
44
|
+
threshold?: number;
|
|
45
|
+
/** IntersectionObserver rootMargin. */
|
|
46
|
+
rootMargin?: string;
|
|
47
|
+
/** Scroll-linked: progress follows the element's entry instead of triggering once. Ignores stagger. */
|
|
48
|
+
scrub?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
as: Tag = 'div',
|
|
53
|
+
from = 'bottom',
|
|
54
|
+
distance,
|
|
55
|
+
scale,
|
|
56
|
+
blur,
|
|
57
|
+
duration,
|
|
58
|
+
delay,
|
|
59
|
+
easing,
|
|
60
|
+
stagger,
|
|
61
|
+
once = true,
|
|
62
|
+
threshold,
|
|
63
|
+
rootMargin,
|
|
64
|
+
scrub = false,
|
|
65
|
+
class: className,
|
|
66
|
+
style,
|
|
67
|
+
...rest
|
|
68
|
+
} = Astro.props;
|
|
69
|
+
|
|
70
|
+
// Only non-default values become inline custom properties; defaults live in the CSS. An unset distance is the preset's
|
|
71
|
+
// travel from any side: it used to follow the preset from the bottom only (the CSS default) and be pinned at 24 px from
|
|
72
|
+
// the top, the left and the right.
|
|
73
|
+
const vars: string[] = [];
|
|
74
|
+
const travel = distance === undefined ? 'var(--ma-travel-enter, 24px)' : `${distance}px`;
|
|
75
|
+
if (from === 'left' || from === 'right') vars.push(`--ma-dx:${from === 'left' ? `calc(-1 * ${travel})` : travel}`, '--ma-dy:0px');
|
|
76
|
+
else if (from === 'top') vars.push(`--ma-dy:calc(-1 * ${travel})`);
|
|
77
|
+
else if (from !== 'bottom') vars.push('--ma-dy:0px');
|
|
78
|
+
else if (distance !== undefined) vars.push(`--ma-dy:${travel}`);
|
|
79
|
+
if (scale !== undefined && scale !== 1) vars.push(`--ma-scale:${scale}`);
|
|
80
|
+
if (blur) vars.push(`--ma-blur:${blur}px`);
|
|
81
|
+
if (duration !== undefined) vars.push(`--ma-r-dur:${duration}ms`);
|
|
82
|
+
if (delay) vars.push(`--ma-r-delay:${delay}ms`);
|
|
83
|
+
if (easing) vars.push(`--ma-r-ease:${easing}`);
|
|
84
|
+
// `stagger` alone (true) leaves the interval to the preset's --ma-stagger; it used to print --ma-r-stagger:truems
|
|
85
|
+
if (typeof stagger === 'number' && stagger) vars.push(`--ma-r-stagger:${stagger}ms`);
|
|
86
|
+
if (typeof style === 'string' && style) vars.push(style);
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
<Tag
|
|
90
|
+
class:list={['ma-reveal', className]}
|
|
91
|
+
data-ma-reveal
|
|
92
|
+
data-once={once ? undefined : 'false'}
|
|
93
|
+
data-stagger={stagger ? '' : undefined}
|
|
94
|
+
data-scrub={scrub ? '' : undefined}
|
|
95
|
+
data-from={from === 'clip' ? 'clip' : undefined}
|
|
96
|
+
data-threshold={threshold}
|
|
97
|
+
data-root-margin={rootMargin}
|
|
98
|
+
style={vars.length ? vars.join(';') : undefined}
|
|
99
|
+
{...rest}
|
|
100
|
+
>
|
|
101
|
+
<slot />
|
|
102
|
+
</Tag>
|
|
103
|
+
|
|
104
|
+
<style is:global>
|
|
105
|
+
@layer components {
|
|
106
|
+
/* Hidden state — gated on html[data-ma-js] so server HTML stays readable. */
|
|
107
|
+
:where([data-ma-js] .ma-reveal:not([data-stagger]):not([data-scrub]):not(.ma-in)),
|
|
108
|
+
:where([data-ma-js] .ma-reveal[data-stagger]:not([data-scrub]):not(.ma-in) > *) {
|
|
109
|
+
opacity: 0;
|
|
110
|
+
translate: var(--ma-dx, 0px) var(--ma-dy, var(--ma-travel-enter, 24px));
|
|
111
|
+
scale: var(--ma-scale, 1);
|
|
112
|
+
filter: blur(var(--ma-blur, 0px));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
:where(.ma-reveal:not([data-stagger]):not([data-scrub])),
|
|
116
|
+
:where(.ma-reveal[data-stagger]:not([data-scrub]) > *) {
|
|
117
|
+
transition-property: opacity, translate, scale, filter, mask-size;
|
|
118
|
+
transition-duration: var(--ma-r-dur, var(--ma-duration, 450ms));
|
|
119
|
+
transition-timing-function: var(--ma-r-ease, var(--ma-ease, ease-out));
|
|
120
|
+
transition-delay: var(--ma-r-delay, 0ms);
|
|
121
|
+
}
|
|
122
|
+
:where(.ma-reveal[data-stagger]:not([data-scrub]) > *) {
|
|
123
|
+
transition-delay: calc(var(--ma-r-delay, 0ms) + var(--ma-r-i, 0) * var(--ma-r-stagger, var(--ma-stagger, 60ms)));
|
|
124
|
+
}
|
|
125
|
+
/* from="clip": the element is fully painted and wipes open from the bottom — no fade, no travel. A mask, not
|
|
126
|
+
clip-path, on purpose: Chromium's IntersectionObserver applies the target's own clip-path, so an element
|
|
127
|
+
clipped to nothing never intersects and would never reveal. The mask is not part of the intersection. */
|
|
128
|
+
:where(.ma-reveal[data-from='clip']:not([data-stagger]):not([data-scrub])),
|
|
129
|
+
:where(.ma-reveal[data-from='clip'][data-stagger]:not([data-scrub]) > *) {
|
|
130
|
+
mask-image: linear-gradient(#000 0 0);
|
|
131
|
+
mask-repeat: no-repeat;
|
|
132
|
+
mask-position: 0 100%;
|
|
133
|
+
mask-size: 100% 100%;
|
|
134
|
+
}
|
|
135
|
+
:where([data-ma-js] .ma-reveal[data-from='clip']:not([data-stagger]):not([data-scrub]):not(.ma-in)),
|
|
136
|
+
:where([data-ma-js] .ma-reveal[data-from='clip'][data-stagger]:not([data-scrub]):not(.ma-in) > *) {
|
|
137
|
+
opacity: 1;
|
|
138
|
+
translate: 0 0;
|
|
139
|
+
mask-size: 100% 0%;
|
|
140
|
+
}
|
|
141
|
+
/* Leaving (once=false): out fast, no stagger — symmetry would read as a wait. */
|
|
142
|
+
:where(.ma-reveal[data-leaving]),
|
|
143
|
+
:where(.ma-reveal[data-leaving] > *) {
|
|
144
|
+
transition-duration: var(--ma-duration-fast);
|
|
145
|
+
transition-delay: 0ms;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Scrub: scroll-linked in browsers with view timelines … */
|
|
149
|
+
@supports (animation-timeline: view()) {
|
|
150
|
+
:where([data-ma-js] .ma-reveal[data-scrub]) {
|
|
151
|
+
animation: ma-reveal linear both;
|
|
152
|
+
}
|
|
153
|
+
/* Separate rule with a different (equivalent) selector on purpose:
|
|
154
|
+
lightningcss folds animation-timeline into the `animation` shorthand,
|
|
155
|
+
and browsers reject that declaration because the shorthand cannot set
|
|
156
|
+
a timeline. Keeping the selectors distinct prevents the merge. */
|
|
157
|
+
:where([data-ma-js]) :where(.ma-reveal[data-scrub]) {
|
|
158
|
+
animation-timeline: view();
|
|
159
|
+
animation-range: var(--ma-range, entry 0% entry 100%);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/* … and driven by a scroll listener writing --ma-p everywhere else: a raw ratio, clamped here to 0–1. */
|
|
163
|
+
:where([data-ma-js] .ma-reveal[data-scrub][data-ma-fallback]) {
|
|
164
|
+
opacity: clamp(0, var(--ma-p, 1), 1);
|
|
165
|
+
translate: calc(var(--ma-dx, 0px) * (1 - clamp(0, var(--ma-p, 1), 1))) calc(var(--ma-dy, var(--ma-travel-enter, 24px)) * (1 - clamp(0, var(--ma-p, 1), 1)));
|
|
166
|
+
transition: opacity 120ms linear, translate 120ms linear;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* Reduced motion: keep the fade, drop the travel. Declared, not overridden
|
|
170
|
+
through the custom properties, because inline style would win. */
|
|
171
|
+
@media (prefers-reduced-motion: reduce) {
|
|
172
|
+
:where([data-ma-js] .ma-reveal:not(.ma-in)),
|
|
173
|
+
:where([data-ma-js] .ma-reveal[data-stagger]:not(.ma-in) > *) {
|
|
174
|
+
translate: 0 0;
|
|
175
|
+
scale: 1;
|
|
176
|
+
filter: none;
|
|
177
|
+
}
|
|
178
|
+
/* clip becomes the fade everything else gets */
|
|
179
|
+
:where([data-ma-js] .ma-reveal[data-from='clip']:not(.ma-in)),
|
|
180
|
+
:where([data-ma-js] .ma-reveal[data-from='clip'][data-stagger]:not(.ma-in) > *) {
|
|
181
|
+
mask-size: 100% 100%;
|
|
182
|
+
opacity: 0;
|
|
183
|
+
}
|
|
184
|
+
:where(.ma-reveal),
|
|
185
|
+
:where(.ma-reveal > *) {
|
|
186
|
+
transition-duration: var(--ma-duration-fast);
|
|
187
|
+
transition-delay: 0ms;
|
|
188
|
+
}
|
|
189
|
+
:where([data-ma-js] .ma-reveal[data-scrub]) {
|
|
190
|
+
animation: none;
|
|
191
|
+
opacity: 1;
|
|
192
|
+
translate: 0 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* Print: the end state, with no transition into it. Switching to print media is a style change like any other, so
|
|
197
|
+
a transition ran from the hidden state and the page printed at its first frame (opacity 0). */
|
|
198
|
+
@media print {
|
|
199
|
+
:where(.ma-reveal),
|
|
200
|
+
:where(.ma-reveal > *) {
|
|
201
|
+
opacity: 1;
|
|
202
|
+
translate: none;
|
|
203
|
+
scale: none;
|
|
204
|
+
filter: none;
|
|
205
|
+
mask-image: none;
|
|
206
|
+
animation: none;
|
|
207
|
+
transition: none;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@keyframes ma-reveal {
|
|
213
|
+
from {
|
|
214
|
+
opacity: 0;
|
|
215
|
+
translate: var(--ma-dx, 0px) var(--ma-dy, var(--ma-travel-enter, 24px));
|
|
216
|
+
scale: var(--ma-scale, 1);
|
|
217
|
+
}
|
|
218
|
+
to {
|
|
219
|
+
opacity: 1;
|
|
220
|
+
translate: 0 0;
|
|
221
|
+
scale: 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
</style>
|
|
225
|
+
|
|
226
|
+
<script>
|
|
227
|
+
import { onMount } from '../lib/runtime';
|
|
228
|
+
|
|
229
|
+
const NATIVE_SCRUB = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: view()');
|
|
230
|
+
|
|
231
|
+
onMount<HTMLElement>('[data-ma-reveal]', (el, { signal }) => {
|
|
232
|
+
const targets = el.hasAttribute('data-stagger') ? (Array.from(el.children) as HTMLElement[]) : [el];
|
|
233
|
+
if (targets.length > 1) targets.forEach((t, i) => t.style.setProperty('--ma-r-i', String(i)));
|
|
234
|
+
|
|
235
|
+
if (el.hasAttribute('data-scrub')) {
|
|
236
|
+
if (NATIVE_SCRUB) return;
|
|
237
|
+
el.dataset.maFallback = '';
|
|
238
|
+
// The entry range view() measures: 0 while the top is below the viewport, 1 once the element has come in by its
|
|
239
|
+
// own height or the viewport's, whichever is less, and 1 from then on (the CSS clamps the ratio to that range).
|
|
240
|
+
// The intersection ratio this used to read never reached 1 for an element taller than the viewport, and fell
|
|
241
|
+
// again as the element left at the top. Capture hears a scroller inside the page as well.
|
|
242
|
+
const set = () => {
|
|
243
|
+
const r = el.getBoundingClientRect();
|
|
244
|
+
el.style.setProperty('--ma-p', `${(innerHeight - r.top) / Math.min(r.height, innerHeight)}`);
|
|
245
|
+
};
|
|
246
|
+
set();
|
|
247
|
+
for (const type of ['scroll', 'resize']) addEventListener(type, set, { signal, capture: true });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const once = el.dataset.once !== 'false';
|
|
252
|
+
const done = () => once && el.classList.contains('ma-in');
|
|
253
|
+
// will-change is a loan: taken when the reveal starts, repaid when the last target settles. Any property settles
|
|
254
|
+
// it, since from="clip" moves mask-size and never opacity; an empty staggered group has no last target to wait for.
|
|
255
|
+
const will = (v: string) => targets.forEach((t) => (t.style.willChange = v));
|
|
256
|
+
// Revealed already and played once: an element persisted across a swap keeps its state, and repays a loan the swap
|
|
257
|
+
// cut off before its transition ended. Observed again, it was hidden as soon as it was off screen and replayed on
|
|
258
|
+
// the way back.
|
|
259
|
+
if (done()) return will('');
|
|
260
|
+
targets.at(-1)?.addEventListener('transitionend', () => will(''), { signal });
|
|
261
|
+
|
|
262
|
+
// `threshold` is a share of the element, and a group taller than the screen may never show that share: eight cards
|
|
263
|
+
// stacked on a phone are 3 000 px, and 20 % of them is more than a 568 px screen holds, so they never appeared.
|
|
264
|
+
// Every observer's first entry says how tall the element is against the viewport; past it, the share becomes that
|
|
265
|
+
// fraction of the viewport's own height and the observer is set again. A resize starts over.
|
|
266
|
+
const share = Number(el.dataset.threshold ?? 0.2);
|
|
267
|
+
let io: IntersectionObserver;
|
|
268
|
+
const watch = (threshold = share) => {
|
|
269
|
+
io?.disconnect();
|
|
270
|
+
io = new IntersectionObserver(
|
|
271
|
+
([e]) => {
|
|
272
|
+
const fit = Math.min(share, (share * innerHeight) / e!.boundingClientRect.height);
|
|
273
|
+
if (fit < threshold) return watch(fit);
|
|
274
|
+
if (e!.isIntersecting) {
|
|
275
|
+
will('translate, opacity');
|
|
276
|
+
delete el.dataset.leaving;
|
|
277
|
+
el.classList.add('ma-in');
|
|
278
|
+
if (once) io.disconnect();
|
|
279
|
+
} else if (el.classList.contains('ma-in')) {
|
|
280
|
+
el.dataset.leaving = '';
|
|
281
|
+
el.classList.remove('ma-in');
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
{ threshold, rootMargin: el.dataset.rootMargin ?? '0px 0px -10% 0px' },
|
|
285
|
+
);
|
|
286
|
+
io.observe(el);
|
|
287
|
+
};
|
|
288
|
+
watch();
|
|
289
|
+
addEventListener('resize', () => done() || watch(), { signal });
|
|
290
|
+
signal.addEventListener('abort', () => io.disconnect());
|
|
291
|
+
});
|
|
292
|
+
</script>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Reveal',
|
|
5
|
+
slug: 'reveal',
|
|
6
|
+
title: 'Scroll Reveal',
|
|
7
|
+
description:
|
|
8
|
+
'Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation. Server HTML stays readable without JavaScript.',
|
|
9
|
+
summary: 'A scroll reveal animation that plays once, staggers children, can scrub with the scroll, and survives ClientRouter.',
|
|
10
|
+
category: 'scroll',
|
|
11
|
+
tier: 'B',
|
|
12
|
+
trigger: ['scroll'],
|
|
13
|
+
support: { baseline: 'widely', needs: [], fallback: 'scrub uses animation-timeline: view() where it exists and, everywhere else, a scroll listener that measures the same entry range; from="clip" wipes with mask-size (Chrome 120, Firefox 53, Safari 15.4) and simply appears before that' },
|
|
14
|
+
readout: '<Reveal stagger={60}>',
|
|
15
|
+
usage: `<Reveal from="bottom" distance={24}>\n <h2>Appears once, stays.</h2>\n</Reveal>\n\n<Reveal as="ul" stagger={60}>\n <li>one</li><li>two</li><li>three</li>\n</Reveal>\n\n<Reveal from="clip">\n <img src="/cover.jpg" alt="" />\n</Reveal>`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'as', type: 'HTMLTag', default: "'div'", description: 'Element to render.' },
|
|
18
|
+
{ name: 'from', type: "'bottom' | 'top' | 'left' | 'right' | 'clip' | 'none'", default: "'bottom'", description: 'Where the element travels from. clip wipes the element open from its bottom edge (fully painted, no fade, no travel); none fades only.' },
|
|
19
|
+
{ name: 'distance', type: 'number', default: 'preset (ui: 24)', description: 'Travel distance in px. Unset, it follows the preset\'s --ma-travel-enter from whichever side it travels; set, it pins this instance.' },
|
|
20
|
+
{ name: 'scale', type: 'number', description: 'Scale to grow from, e.g. 0.96. Never below 0.9.' },
|
|
21
|
+
{ name: 'blur', type: 'number', description: 'Blur in px to sharpen from. Costly on large elements; keep under 8.' },
|
|
22
|
+
{ name: 'duration', type: 'number', default: 'preset (ui: 450)', description: 'Duration in ms. Unset, it follows the active preset (data-ma-preset).' },
|
|
23
|
+
{ name: 'delay', type: 'number', default: '0', description: 'Delay in ms.' },
|
|
24
|
+
{ name: 'easing', type: 'string', default: 'preset curve', description: 'Any CSS easing. Unset, it is the preset\'s sampled spring.' },
|
|
25
|
+
{ name: 'stagger', type: 'number | true', description: 'Interval in ms between direct children; the preset\'s --ma-stagger when set without a value. When set, the children animate instead of the wrapper. 30–80 ms reads well.' },
|
|
26
|
+
{ name: 'once', type: 'boolean', default: 'true', description: 'Play once and stay. false re-plays on every entry, leaving quickly and without stagger.' },
|
|
27
|
+
{ name: 'threshold', type: 'number', default: '0.2', description: "Fraction of the element that must be visible to trigger; for an element taller than the viewport, that fraction of the viewport's height." },
|
|
28
|
+
{ name: 'rootMargin', type: 'string', default: "'0px 0px -10% 0px'", description: 'IntersectionObserver rootMargin; the default waits until the element is 10% into the viewport.' },
|
|
29
|
+
{ name: 'scrub', type: 'boolean', default: 'false', description: 'Scroll-linked: progress follows entry into the viewport. Uses animation-timeline: view() where supported; elsewhere a scroll listener measures the same entry range, so a tall element still reaches full and stays full as it leaves.' },
|
|
30
|
+
],
|
|
31
|
+
reducedMotion: 'Keeps a fast opacity fade, drops translate, scale, blur and stagger. Scrub becomes static.',
|
|
32
|
+
clientRouter:
|
|
33
|
+
'Binds through the shared runtime: every instance on the new page is observed after each navigation, observers are disconnected before the swap, and the html[data-ma-js] gate is restored so nothing flashes. A once-reveal persisted across the swap (transition:persist) keeps its revealed state instead of being observed again.',
|
|
34
|
+
craft: [
|
|
35
|
+
'Curve, duration, travel and stagger resolve from the active preset, so one attribute on <html> retunes every reveal on the page; a prop pins one instance.',
|
|
36
|
+
'24 px of travel is visible without reading as a slide; scale never below 0.9.',
|
|
37
|
+
'Stagger 30–80 ms; cap the group: ten items at 60 ms is 600 ms of waiting.',
|
|
38
|
+
'Leaving (once=false) is fast and unstaggered so re-entry never fights the reader.',
|
|
39
|
+
'Hidden state is gated on html[data-ma-js]; without JS the server HTML is the end state.',
|
|
40
|
+
'from="clip" is a wipe, not a fade: the element is painted in full and a mask grows from its bottom edge to full height on the same preset curve. An image or a card appears as if uncovered. It is a mask rather than clip-path because Chromium\'s IntersectionObserver applies the target\'s clip-path: an element clipped to nothing never intersects, so it would never reveal.',
|
|
41
|
+
],
|
|
42
|
+
related: ['split-text', 'marquee'],
|
|
43
|
+
intents: ['scroll reveal', 'fade in on scroll', 'animate on scroll', 'AOS alternative', 'stagger children on scroll', 'scroll-linked reveal', 'view transitions safe reveal'],
|
|
44
|
+
replaces: ['AOS', 'AnimatedContent (React Bits)', 'FadeContent (React Bits)', 'ScrollReveal (React Bits)', 'BlurText (React Bits)', 'motion.div whileInView'],
|
|
45
|
+
// stagger alone takes the preset's interval (it printed --ma-r-stagger:truems), and from="top" with no distance
|
|
46
|
+
// travels the preset's distance (it was pinned at -24px)
|
|
47
|
+
fixture: { props: { from: 'top', stagger: true }, slot: '<p>Fixture content for Reveal</p>', expect: 'data-stagger style="--ma-dy:calc(-1 * var(--ma-travel-enter, 24px))" class="ma-reveal"' },
|
|
48
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Ripple — rings expand from a point and fade, one after another. Zero JS:
|
|
4
|
+
* each ring is an element scaling and fading on its own delay, so the set
|
|
5
|
+
* reads as a continuous pulse. Fills its positioned parent.
|
|
6
|
+
*/
|
|
7
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
8
|
+
|
|
9
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
10
|
+
/** Number of rings in flight. */
|
|
11
|
+
count?: number;
|
|
12
|
+
/** Seconds a ring takes to expand and fade. */
|
|
13
|
+
duration?: number;
|
|
14
|
+
/** Ring colour. */
|
|
15
|
+
color?: string;
|
|
16
|
+
/** Final diameter as a fraction of the box's width, capped at the nearest container's inline size. */
|
|
17
|
+
size?: number;
|
|
18
|
+
/** Origin, x then y as in background-position: lengths, percentages or keywords; one value centres the other axis. */
|
|
19
|
+
origin?: string;
|
|
20
|
+
/** Ring thickness in px. */
|
|
21
|
+
width?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { count = 4, duration = 4, color = 'currentColor', size = 1.4, origin = '50% 50%', width = 1, class: className, style, ...rest } = Astro.props;
|
|
25
|
+
const n = Math.max(1, Math.min(count, 8));
|
|
26
|
+
// origin → one left and one top. Written into the inset shorthand it was top and right (inset is top right bottom left),
|
|
27
|
+
// so the default 50% 50% put the rings' centre half a ring left of the box's. Split at the top level (a calc() keeps its
|
|
28
|
+
// spaces), keywords to percentages, and "top left" read as y x, as background-position reads it.
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let part = '';
|
|
32
|
+
for (const ch of origin.trim()) {
|
|
33
|
+
if (ch === '(') depth++;
|
|
34
|
+
else if (ch === ')') depth--;
|
|
35
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
36
|
+
if (part) parts.push(part);
|
|
37
|
+
part = '';
|
|
38
|
+
} else part += ch;
|
|
39
|
+
}
|
|
40
|
+
if (part) parts.push(part);
|
|
41
|
+
let [x = 'center', y = 'center'] = parts;
|
|
42
|
+
if (/^(top|bottom)$/.test(x) || /^(left|right)$/.test(y)) [x, y] = [y, x];
|
|
43
|
+
const KEYWORD: Record<string, string> = { left: '0%', top: '0%', center: '50%', right: '100%', bottom: '100%' };
|
|
44
|
+
const vars = [`--ma-rp-n:${n};--ma-rp-dur:${duration}s;--ma-rp-color:${color};--ma-rp-size:${size};--ma-rp-x:${KEYWORD[x] ?? x};--ma-rp-y:${KEYWORD[y] ?? y};--ma-rp-w:${width}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<div class:list={['ma-ripple', className]} aria-hidden="true" style={vars} {...rest}>
|
|
48
|
+
{Array.from({ length: n }, (_, i) => <i style={`--ma-rp-i:${i}`}></i>)}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<style is:global>
|
|
52
|
+
@layer components {
|
|
53
|
+
:where(.ma-ripple) {
|
|
54
|
+
position: absolute;
|
|
55
|
+
inset: 0;
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
pointer-events: none;
|
|
58
|
+
contain: strict;
|
|
59
|
+
}
|
|
60
|
+
:where(.ma-ripple > i) {
|
|
61
|
+
position: absolute;
|
|
62
|
+
left: var(--ma-rp-x, 50%);
|
|
63
|
+
top: var(--ma-rp-y, 50%);
|
|
64
|
+
width: calc(min(100%, 100cqi) * var(--ma-rp-size, 1.4));
|
|
65
|
+
aspect-ratio: 1;
|
|
66
|
+
border: var(--ma-rp-w, 1px) solid var(--ma-rp-color, currentColor);
|
|
67
|
+
border-radius: 50%;
|
|
68
|
+
translate: -50% -50%;
|
|
69
|
+
opacity: 0;
|
|
70
|
+
scale: 0;
|
|
71
|
+
will-change: scale, opacity;
|
|
72
|
+
animation: ma-ripple var(--ma-rp-dur, 4s) var(--ma-ease-out) infinite;
|
|
73
|
+
animation-delay: calc(var(--ma-rp-i, 0) * var(--ma-rp-dur, 4s) / var(--ma-rp-n, 4));
|
|
74
|
+
}
|
|
75
|
+
@media (prefers-reduced-motion: reduce) {
|
|
76
|
+
:where(.ma-ripple > i) {
|
|
77
|
+
animation: none;
|
|
78
|
+
opacity: 0.25;
|
|
79
|
+
scale: calc(0.3 + var(--ma-rp-i, 0) * 0.2);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@keyframes ma-ripple {
|
|
85
|
+
0% {
|
|
86
|
+
opacity: 0.6;
|
|
87
|
+
scale: 0;
|
|
88
|
+
}
|
|
89
|
+
100% {
|
|
90
|
+
opacity: 0;
|
|
91
|
+
scale: 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Ripple',
|
|
5
|
+
slug: 'ripple',
|
|
6
|
+
title: 'Ripple',
|
|
7
|
+
description: 'Rings expand from a point and fade, one after another, like a pulse. Each ring is an element scaling and fading on its own delay; pure CSS, compositor only. Fills its positioned parent.',
|
|
8
|
+
summary: 'Rings that expand from a point and fade one after another, like a pulse.',
|
|
9
|
+
category: 'background',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['always'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<Ripple count={4}>',
|
|
14
|
+
usage: `<div class="relative grid h-64 place-items-center">\n <Ripple color="var(--accent)" />\n <button class="relative rounded-full bg-fg px-5 py-3 text-bg">Live</button>\n</div>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'count', type: 'number', default: '4', description: 'Rings in flight, 1–8; evenly spaced over the duration.' },
|
|
17
|
+
{ name: 'duration', type: 'number', default: '4', description: 'Seconds a ring takes to expand and fade.' },
|
|
18
|
+
{ name: 'color', type: 'string', default: "'currentColor'", description: 'Ring colour.' },
|
|
19
|
+
{ name: 'size', type: 'number', default: '1.4', description: 'Final diameter as a fraction of the box\'s width (the positioned parent it fills), capped at the nearest container\'s inline size.' },
|
|
20
|
+
{ name: 'origin', type: 'string', default: "'50% 50%'", description: 'Where the rings start, x then y as in background-position: lengths, percentages or the keywords left, center, right, top and bottom ("top left" works too); one value centres the other axis. The centre of every ring sits on this point.' },
|
|
21
|
+
{ name: 'width', type: 'number', default: '1', description: 'Ring thickness in px.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'Four static concentric rings at a quarter opacity.',
|
|
24
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
25
|
+
craft: [
|
|
26
|
+
'Ease-out: a ring slows as it grows, like water. Linear rings look like radar.',
|
|
27
|
+
'Delays are duration / count, so the pulse is even and the last ring hands off to the first.',
|
|
28
|
+
'Opacity starts at 0.6, never 1: rings are texture behind content, not a subject.',
|
|
29
|
+
],
|
|
30
|
+
related: ['dot-pattern', 'aurora', 'spinner'],
|
|
31
|
+
intents: ['ripple effect', 'pulsing rings', 'radar pulse background', 'concentric circles animation'],
|
|
32
|
+
replaces: ['Ripple (Magic UI)', 'pulse rings (custom CSS)'],
|
|
33
|
+
fixture: { expect: 'class="ma-ripple"' },
|
|
34
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* RotatingText — cycles through words in place, zero JS.
|
|
4
|
+
*
|
|
5
|
+
* Every custom property is namespaced (--ma-rotate-*): @property is global,
|
|
6
|
+
* and a short name registered by one component as non-inherited breaks any
|
|
7
|
+
* other component that reads the same name through inheritance.
|
|
8
|
+
*
|
|
9
|
+
* Words stack in one grid cell so the box is always as wide as the widest
|
|
10
|
+
* word; each word runs the same keyframes offset by its index. The travel is a
|
|
11
|
+
* registered custom property (--ma-rotate-y) so reduced motion can neutralise it with
|
|
12
|
+
* one declaration and keep the crossfade.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
15
|
+
|
|
16
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
17
|
+
/** Words to cycle through, 2–8. A single word stays in place. */
|
|
18
|
+
words: string[];
|
|
19
|
+
/** Element to render. */
|
|
20
|
+
as?: HTMLTag;
|
|
21
|
+
/** Time each word is shown, ms (includes its entrance). */
|
|
22
|
+
period?: number;
|
|
23
|
+
/** The box follows the current word's width, so the sentence around it shifts smoothly instead of being sized to the widest word. */
|
|
24
|
+
fluid?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { words, as: Tag = 'span', period = 2200, fluid = false, class: className, style, ...rest } = Astro.props;
|
|
28
|
+
const list: string[] = words.slice(0, 8);
|
|
29
|
+
const n = Math.max(list.length, 2);
|
|
30
|
+
const inline = [`--ma-rotate-n:${n}`, `--ma-rotate-period:${period}ms`, `--ma-rotate-anim:ma-rotate-${n}`, fluid && `--ma-rotate-anim-w:ma-rotate-fluid-${n}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<Tag class:list={['ma-rotate', className]} data-fluid={fluid ? '' : undefined} style={inline} {...rest}>
|
|
34
|
+
{list.map((word, i) => <span class="ma-rotate__word" aria-hidden="true" style={`--ma-rotate-i:${i}${fluid ? `;--ma-rotate-len:${[...word].length}` : ''}`}>{word}</span>)}
|
|
35
|
+
<span class="ma-sr">{list.join(', ')}</span>
|
|
36
|
+
</Tag>
|
|
37
|
+
|
|
38
|
+
<style is:global>
|
|
39
|
+
@property --ma-rotate-y {
|
|
40
|
+
syntax: '<length-percentage>';
|
|
41
|
+
inherits: false;
|
|
42
|
+
initial-value: 0%;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@layer components {
|
|
46
|
+
:where(.ma-rotate) {
|
|
47
|
+
display: inline-grid;
|
|
48
|
+
overflow: clip;
|
|
49
|
+
vertical-align: bottom;
|
|
50
|
+
}
|
|
51
|
+
:where(.ma-rotate__word) {
|
|
52
|
+
grid-area: 1 / 1;
|
|
53
|
+
translate: 0 var(--ma-rotate-y);
|
|
54
|
+
opacity: 0;
|
|
55
|
+
animation: var(--ma-rotate-anim) calc(var(--ma-rotate-n) * var(--ma-rotate-period)) linear infinite;
|
|
56
|
+
animation-delay: calc(var(--ma-rotate-i) * var(--ma-rotate-period));
|
|
57
|
+
will-change: translate, opacity;
|
|
58
|
+
}
|
|
59
|
+
/* fluid: a second animation caps each word's max-width — 0 while it is away, its bound while it is the word — so the cell
|
|
60
|
+
(the widest contribution) moves from one word's width to the next instead of sitting at the widest of them all */
|
|
61
|
+
:where(.ma-rotate[data-fluid] .ma-rotate__word) {
|
|
62
|
+
white-space: nowrap;
|
|
63
|
+
--ma-rotate-w: calc(var(--ma-rotate-len, 8) * 0.75em);
|
|
64
|
+
animation-name: var(--ma-rotate-anim), var(--ma-rotate-anim-w);
|
|
65
|
+
}
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
:where(.ma-rotate__word) {
|
|
68
|
+
translate: 0 0;
|
|
69
|
+
will-change: auto;
|
|
70
|
+
}
|
|
71
|
+
:where(.ma-rotate[data-fluid] .ma-rotate__word) {
|
|
72
|
+
animation-name: var(--ma-rotate-anim);
|
|
73
|
+
max-width: none;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/* One word (the first child and the one before .ma-sr) has nothing to rotate to: it stays, reduced motion or not.
|
|
77
|
+
It used to run the two-word cycle against an empty slot and was gone for almost half of every turn. */
|
|
78
|
+
:where(.ma-rotate__word:first-child:nth-last-child(2)) {
|
|
79
|
+
opacity: 1;
|
|
80
|
+
translate: none;
|
|
81
|
+
animation: none;
|
|
82
|
+
will-change: auto;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@keyframes ma-rotate-2 {
|
|
87
|
+
0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
88
|
+
9.00% { --ma-rotate-y: 0%; opacity: 1; }
|
|
89
|
+
50.00% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
|
|
90
|
+
59.00% { --ma-rotate-y: -110%; opacity: 0; }
|
|
91
|
+
100% { --ma-rotate-y: -110%; opacity: 0; }
|
|
92
|
+
}
|
|
93
|
+
@keyframes ma-rotate-3 {
|
|
94
|
+
0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
95
|
+
6.00% { --ma-rotate-y: 0%; opacity: 1; }
|
|
96
|
+
33.33% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
|
|
97
|
+
39.33% { --ma-rotate-y: -110%; opacity: 0; }
|
|
98
|
+
100% { --ma-rotate-y: -110%; opacity: 0; }
|
|
99
|
+
}
|
|
100
|
+
@keyframes ma-rotate-4 {
|
|
101
|
+
0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
102
|
+
4.50% { --ma-rotate-y: 0%; opacity: 1; }
|
|
103
|
+
25.00% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
|
|
104
|
+
29.50% { --ma-rotate-y: -110%; opacity: 0; }
|
|
105
|
+
100% { --ma-rotate-y: -110%; opacity: 0; }
|
|
106
|
+
}
|
|
107
|
+
@keyframes ma-rotate-5 {
|
|
108
|
+
0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
109
|
+
3.60% { --ma-rotate-y: 0%; opacity: 1; }
|
|
110
|
+
20.00% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
|
|
111
|
+
23.60% { --ma-rotate-y: -110%; opacity: 0; }
|
|
112
|
+
100% { --ma-rotate-y: -110%; opacity: 0; }
|
|
113
|
+
}
|
|
114
|
+
@keyframes ma-rotate-6 {
|
|
115
|
+
0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
116
|
+
3.00% { --ma-rotate-y: 0%; opacity: 1; }
|
|
117
|
+
16.67% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
|
|
118
|
+
19.67% { --ma-rotate-y: -110%; opacity: 0; }
|
|
119
|
+
100% { --ma-rotate-y: -110%; opacity: 0; }
|
|
120
|
+
}
|
|
121
|
+
@keyframes ma-rotate-7 {
|
|
122
|
+
0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
123
|
+
2.57% { --ma-rotate-y: 0%; opacity: 1; }
|
|
124
|
+
14.29% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
|
|
125
|
+
16.86% { --ma-rotate-y: -110%; opacity: 0; }
|
|
126
|
+
100% { --ma-rotate-y: -110%; opacity: 0; }
|
|
127
|
+
}
|
|
128
|
+
@keyframes ma-rotate-fluid-2 {
|
|
129
|
+
0% { max-width: 0; }
|
|
130
|
+
9%, 59% { max-width: var(--ma-rotate-w); }
|
|
131
|
+
62%, 100% { max-width: 0; }
|
|
132
|
+
}
|
|
133
|
+
@keyframes ma-rotate-fluid-3 {
|
|
134
|
+
0% { max-width: 0; }
|
|
135
|
+
6%, 39.33% { max-width: var(--ma-rotate-w); }
|
|
136
|
+
41.33%, 100% { max-width: 0; }
|
|
137
|
+
}
|
|
138
|
+
@keyframes ma-rotate-fluid-4 {
|
|
139
|
+
0% { max-width: 0; }
|
|
140
|
+
4.5%, 29.5% { max-width: var(--ma-rotate-w); }
|
|
141
|
+
31%, 100% { max-width: 0; }
|
|
142
|
+
}
|
|
143
|
+
@keyframes ma-rotate-fluid-5 {
|
|
144
|
+
0% { max-width: 0; }
|
|
145
|
+
3.6%, 23.6% { max-width: var(--ma-rotate-w); }
|
|
146
|
+
24.8%, 100% { max-width: 0; }
|
|
147
|
+
}
|
|
148
|
+
@keyframes ma-rotate-fluid-6 {
|
|
149
|
+
0% { max-width: 0; }
|
|
150
|
+
3%, 19.67% { max-width: var(--ma-rotate-w); }
|
|
151
|
+
20.67%, 100% { max-width: 0; }
|
|
152
|
+
}
|
|
153
|
+
@keyframes ma-rotate-fluid-7 {
|
|
154
|
+
0% { max-width: 0; }
|
|
155
|
+
2.57%, 16.86% { max-width: var(--ma-rotate-w); }
|
|
156
|
+
17.71%, 100% { max-width: 0; }
|
|
157
|
+
}
|
|
158
|
+
@keyframes ma-rotate-fluid-8 {
|
|
159
|
+
0% { max-width: 0; }
|
|
160
|
+
2.25%, 14.75% { max-width: var(--ma-rotate-w); }
|
|
161
|
+
15.5%, 100% { max-width: 0; }
|
|
162
|
+
}
|
|
163
|
+
@keyframes ma-rotate-8 {
|
|
164
|
+
0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
165
|
+
2.25% { --ma-rotate-y: 0%; opacity: 1; }
|
|
166
|
+
12.50% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
|
|
167
|
+
14.75% { --ma-rotate-y: -110%; opacity: 0; }
|
|
168
|
+
100% { --ma-rotate-y: -110%; opacity: 0; }
|
|
169
|
+
}
|
|
170
|
+
</style>
|