@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,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* HoverVideo — a poster card that plays a muted clip while the pointer (or
|
|
4
|
+
* focus) is on it and rewinds when it leaves. The card is a link; the video
|
|
5
|
+
* is decoration behind it: muted, playsinline, loop, preload="none", so the
|
|
6
|
+
* page pays nothing until a hover. On coarse pointers a play/pause button
|
|
7
|
+
* appears instead of the hover; under reduced motion it never autoplays and
|
|
8
|
+
* the same button is the only way to play. Without JavaScript the poster
|
|
9
|
+
* and the link stand, and the button (which would do nothing) is not shown.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
12
|
+
import AnimatedIcons from './AnimatedIcons.astro';
|
|
13
|
+
|
|
14
|
+
interface Props extends HTMLAttributes<'figure'> {
|
|
15
|
+
/** The clip; short, muted. Several formats (webm, mp4) as an array — the browser plays the first it can. */
|
|
16
|
+
src: string | string[];
|
|
17
|
+
/** Poster image, shown until it plays and after it rewinds. */
|
|
18
|
+
poster: string;
|
|
19
|
+
/** Where the card goes; default the clip itself. */
|
|
20
|
+
href?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
/** A line under the title. */
|
|
23
|
+
meta?: string;
|
|
24
|
+
/** CSS aspect ratio. */
|
|
25
|
+
ratio?: string;
|
|
26
|
+
/** Accessible name of the play button. */
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { src, poster, href: link, title, meta, ratio = '16 / 10', label = 'Play preview', class: className, style, ...rest } = Astro.props;
|
|
31
|
+
const sources = Array.isArray(src) ? src : [src];
|
|
32
|
+
// the card is always a link, so it takes keyboard focus (which plays the clip): without href it opens the clip itself
|
|
33
|
+
const href = link ?? sources[0];
|
|
34
|
+
const type = (u: string) => (/\.webm(\?|$)/.test(u) ? 'video/webm' : /\.mp4(\?|$)/.test(u) ? 'video/mp4' : undefined);
|
|
35
|
+
const inline = [`--ma-hvideo-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<figure class:list={['ma-hvideo', className]} data-ma-hvideo style={inline} {...rest}>
|
|
39
|
+
<video class="ma-hvideo__video" poster={poster} muted playsinline loop preload="none" tabindex="-1" aria-hidden="true">
|
|
40
|
+
{sources.map((u) => <source src={u} type={type(u)} />)}
|
|
41
|
+
</video>
|
|
42
|
+
<button type="button" class="ma-hvideo__play" aria-label={label} aria-pressed="false">
|
|
43
|
+
<AnimatedIcons icon="play" size="1.1em" />
|
|
44
|
+
</button>
|
|
45
|
+
<figcaption class="ma-hvideo__caption">
|
|
46
|
+
<a href={href} class="ma-hvideo__link">
|
|
47
|
+
{title && <span class="ma-hvideo__title">{title}</span>}
|
|
48
|
+
{meta && <span class="ma-hvideo__meta">{meta}</span>}
|
|
49
|
+
<slot />
|
|
50
|
+
</a>
|
|
51
|
+
</figcaption>
|
|
52
|
+
</figure>
|
|
53
|
+
|
|
54
|
+
<style is:global>
|
|
55
|
+
@layer components {
|
|
56
|
+
:where(.ma-hvideo) {
|
|
57
|
+
position: relative;
|
|
58
|
+
margin: 0;
|
|
59
|
+
aspect-ratio: var(--ma-hvideo-ratio, 16 / 10);
|
|
60
|
+
border: 1px solid var(--ma-edge);
|
|
61
|
+
border-radius: 1rem;
|
|
62
|
+
overflow: clip;
|
|
63
|
+
background: var(--ma-ink);
|
|
64
|
+
color: #fff;
|
|
65
|
+
isolation: isolate;
|
|
66
|
+
}
|
|
67
|
+
:where(.ma-hvideo__video) {
|
|
68
|
+
display: block;
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: 100%;
|
|
71
|
+
object-fit: cover;
|
|
72
|
+
scale: 1;
|
|
73
|
+
transition: scale var(--ma-dur-gentle) var(--ma-ease);
|
|
74
|
+
}
|
|
75
|
+
:where(.ma-hvideo:is(:hover, :focus-within) .ma-hvideo__video) {
|
|
76
|
+
scale: 1.04;
|
|
77
|
+
}
|
|
78
|
+
/* the card is the link: the caption's anchor stretches over the whole figure, the play button sits above it */
|
|
79
|
+
:where(.ma-hvideo__caption) {
|
|
80
|
+
position: absolute;
|
|
81
|
+
inset: auto 0 0;
|
|
82
|
+
padding: 2.5rem 1rem 0.875rem;
|
|
83
|
+
background: linear-gradient(transparent, rgb(0 0 0 / 0.6));
|
|
84
|
+
}
|
|
85
|
+
:where(.ma-hvideo__link) {
|
|
86
|
+
display: grid;
|
|
87
|
+
align-content: center;
|
|
88
|
+
min-block-size: 24px;
|
|
89
|
+
gap: 0.15rem;
|
|
90
|
+
color: inherit;
|
|
91
|
+
text-decoration: none;
|
|
92
|
+
}
|
|
93
|
+
:where(.ma-hvideo__link)::after {
|
|
94
|
+
content: '';
|
|
95
|
+
position: absolute;
|
|
96
|
+
inset: 0;
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-hvideo__title) {
|
|
99
|
+
font-size: 0.9375rem;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
letter-spacing: -0.01em;
|
|
102
|
+
white-space: nowrap;
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
text-overflow: ellipsis;
|
|
105
|
+
}
|
|
106
|
+
:where(.ma-hvideo__meta) {
|
|
107
|
+
font-family: ui-monospace, monospace;
|
|
108
|
+
font-size: 0.6875rem;
|
|
109
|
+
letter-spacing: 0.06em;
|
|
110
|
+
text-transform: uppercase;
|
|
111
|
+
opacity: 0.75;
|
|
112
|
+
}
|
|
113
|
+
:where(.ma-hvideo:has(.ma-hvideo__link:focus-visible)) {
|
|
114
|
+
outline: 2px solid currentColor;
|
|
115
|
+
outline-offset: 2px;
|
|
116
|
+
}
|
|
117
|
+
/* the play button: only where a hover cannot happen, or must not */
|
|
118
|
+
:where(.ma-hvideo__play) {
|
|
119
|
+
position: absolute;
|
|
120
|
+
top: 0.75rem;
|
|
121
|
+
right: 0.75rem;
|
|
122
|
+
z-index: 1;
|
|
123
|
+
display: none;
|
|
124
|
+
place-items: center;
|
|
125
|
+
width: 2.5rem;
|
|
126
|
+
height: 2.5rem;
|
|
127
|
+
border: 1px solid var(--ma-edge);
|
|
128
|
+
border-radius: 999px;
|
|
129
|
+
background: var(--ma-panel);
|
|
130
|
+
color: var(--ma-ink);
|
|
131
|
+
font-size: 1rem;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
transition: scale var(--ma-duration) var(--ma-ease);
|
|
134
|
+
}
|
|
135
|
+
:where(.ma-hvideo__play:focus-visible) {
|
|
136
|
+
outline: 2px solid currentColor;
|
|
137
|
+
outline-offset: 2px;
|
|
138
|
+
}
|
|
139
|
+
/* data-live: the script bound, so the button does something */
|
|
140
|
+
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
|
|
141
|
+
:where(.ma-hvideo[data-live] .ma-hvideo__play) {
|
|
142
|
+
display: grid;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
@media (prefers-reduced-motion: reduce) {
|
|
146
|
+
:where(.ma-hvideo__video),
|
|
147
|
+
:where(.ma-hvideo:is(:hover, :focus-within) .ma-hvideo__video),
|
|
148
|
+
:where(.ma-hvideo__play) {
|
|
149
|
+
scale: 1;
|
|
150
|
+
transition: none;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
</style>
|
|
155
|
+
|
|
156
|
+
<script>
|
|
157
|
+
import { onMount, prefersReducedMotion } from '../lib/runtime';
|
|
158
|
+
|
|
159
|
+
onMount<HTMLElement>('[data-ma-hvideo]', (el, { signal }) => {
|
|
160
|
+
const v = el.querySelector('video')!;
|
|
161
|
+
const btn = el.querySelector('button')!;
|
|
162
|
+
const play = () => v.play().then(() => btn.setAttribute('aria-pressed', 'true')).catch(() => {});
|
|
163
|
+
const stop = () => {
|
|
164
|
+
v.pause();
|
|
165
|
+
v.currentTime = 0;
|
|
166
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
167
|
+
};
|
|
168
|
+
btn.addEventListener('click', () => (v.paused ? play() : stop()), { signal });
|
|
169
|
+
signal.addEventListener('abort', () => v.pause());
|
|
170
|
+
el.dataset.live = '';
|
|
171
|
+
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
|
172
|
+
// hover and focus play; reduced motion is read at each hover so the page's switch applies at once
|
|
173
|
+
const hover = () => !prefersReducedMotion() && play();
|
|
174
|
+
el.addEventListener('pointerenter', hover, { signal });
|
|
175
|
+
el.addEventListener('focusin', hover, { signal });
|
|
176
|
+
el.addEventListener('pointerleave', stop, { signal });
|
|
177
|
+
el.addEventListener('focusout', stop, { signal });
|
|
178
|
+
});
|
|
179
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'HoverVideo',
|
|
5
|
+
slug: 'hover-video',
|
|
6
|
+
title: 'Hover Video',
|
|
7
|
+
description: 'A poster card that plays a muted clip while the pointer or focus is on it and rewinds when it leaves. The card is a link, the video decoration behind it with preload="none", so nothing loads until a hover; coarse pointers get a play/pause button, reduced motion never autoplays. The script is play(), pause() and a rewind.',
|
|
8
|
+
summary: 'A poster card that plays a muted clip on hover or focus and rewinds on leave. Nothing loads before the hover.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['hover'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<HoverVideo poster="…">',
|
|
14
|
+
usage: `<HoverVideo\n src={['/work/loop.webm', '/work/loop.mp4']}\n poster="/work/loop.jpg"\n href="/work/identity/"\n title="Identity for a bakery"\n meta="Brand · 2025"\n/>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'src', type: 'string | string[]', description: 'The clip; short and silent (it is muted anyway). An array is several formats (webm first, then mp4), and the browser plays the first it can.' },
|
|
17
|
+
{ name: 'poster', type: 'string', description: 'The poster image, shown until it plays and after it rewinds.' },
|
|
18
|
+
{ name: 'href', type: 'string', description: 'Where the card links; default the clip (the first source), so the card is always a link and keyboard focus can play it.' },
|
|
19
|
+
{ name: 'title', type: 'string', description: 'Caption title.' },
|
|
20
|
+
{ name: 'meta', type: 'string', description: 'A mono line under the title.' },
|
|
21
|
+
{ name: 'ratio', type: 'string', default: "'16 / 10'", description: 'CSS aspect ratio of the card.' },
|
|
22
|
+
{ name: 'label', type: 'string', default: "'Play preview'", description: 'Accessible name of the play button.' },
|
|
23
|
+
],
|
|
24
|
+
reducedMotion: 'Never autoplays: the poster stands, the play/pause button appears and is the only way to play. Read live, so the page\'s switch applies on the next hover.',
|
|
25
|
+
clientRouter: 'Bound through the shared runtime; a playing clip is paused before the swap.',
|
|
26
|
+
craft: [
|
|
27
|
+
'preload="none" is the budget: a grid of twelve cards downloads nothing until one is hovered, and a poster is the whole cost of the page.',
|
|
28
|
+
'The card is the link and the video is aria-hidden decoration: the title is what the link says, the clip is what the eye gets.',
|
|
29
|
+
'Leaving rewinds to the poster, so every hover starts the clip from its first frame and the grid never shows twelve clips paused at random moments.',
|
|
30
|
+
'The video element scales 4 % on hover on the gentle duration, which lifts the card without a shadow. Poster and clip are the same element, so the first frame replaces the poster in place, with no cross-fade.',
|
|
31
|
+
'Where a hover cannot happen (touch) or must not (reduced motion) the same script wires a real button with aria-pressed, and AnimatedIcons turns its triangle into the pause bars. The button shows only once the script has bound, so without JavaScript nobody is offered a control that does nothing.',
|
|
32
|
+
],
|
|
33
|
+
related: ['video-dialog', 'lightbox', 'animated-icons'],
|
|
34
|
+
intents: ['play video on hover', 'hover to play preview', 'video card hover', 'muted autoplay on hover astro', 'portfolio video thumbnails', 'work grid video preview'],
|
|
35
|
+
replaces: ['HoverVideo (Cult UI)', 'VideoCard (Aceternity)', 'react-hover-video-player'],
|
|
36
|
+
fixture: { props: { src: '/x.mp4', poster: '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', title: 'Fixture content for HoverVideo' }, expect: 'Fixture content for HoverVideo' },
|
|
37
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ImageCompare — before and after, a line you drag between them. A native
|
|
4
|
+
* <input type="range"> lies over the whole box, invisible: the drag, the
|
|
5
|
+
* click-to-jump, the arrow keys, the touch and the screen reader are the
|
|
6
|
+
* browser's. The script writes the value to one custom property; the
|
|
7
|
+
* "after" image is clipped to it with clip-path: inset() and the handle
|
|
8
|
+
* sits on it. While the thumb is held there is no transition; a keyboard
|
|
9
|
+
* step or a click glides on the fast token. Without JavaScript the line
|
|
10
|
+
* stands at `start` and both halves show. Right to left, the range runs
|
|
11
|
+
* from the right, so "before" sits on the inline start (the right) and the
|
|
12
|
+
* line still follows the pointer.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
15
|
+
|
|
16
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
17
|
+
/** The image under the inline-start (left; right on a right-to-left page) or top half. */
|
|
18
|
+
before: string;
|
|
19
|
+
/** The image under the inline-end or bottom half. */
|
|
20
|
+
after: string;
|
|
21
|
+
beforeAlt?: string;
|
|
22
|
+
afterAlt?: string;
|
|
23
|
+
/** Initial position, 0–100. */
|
|
24
|
+
start?: number;
|
|
25
|
+
orientation?: 'horizontal' | 'vertical';
|
|
26
|
+
/** Corner labels; false hides them. */
|
|
27
|
+
labels?: [string, string] | false;
|
|
28
|
+
/** Accessible name of the slider. */
|
|
29
|
+
label?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { before, after, beforeAlt = '', afterAlt = '', start = 50, orientation = 'horizontal', labels = ['Before', 'After'], label = 'Compare', class: className, style, ...rest } = Astro.props;
|
|
33
|
+
const inline = [`--ma-imgcmp:${Math.max(0, Math.min(100, start))}%`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<div class:list={['ma-imgcmp', className]} data-ma-imgcmp data-orientation={orientation} style={inline} {...rest}>
|
|
37
|
+
<img class="ma-imgcmp__before" src={before} alt={beforeAlt} draggable="false" />
|
|
38
|
+
<img class="ma-imgcmp__after" src={after} alt={afterAlt} draggable="false" />
|
|
39
|
+
{labels && (
|
|
40
|
+
<>
|
|
41
|
+
<span class="ma-imgcmp__label ma-imgcmp__label--a" aria-hidden="true">{labels[0]}</span>
|
|
42
|
+
<span class="ma-imgcmp__label ma-imgcmp__label--b" aria-hidden="true">{labels[1]}</span>
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
45
|
+
<span class="ma-imgcmp__handle" aria-hidden="true">
|
|
46
|
+
<span class="ma-imgcmp__knob">
|
|
47
|
+
<svg viewBox="0 0 20 20" width="16" height="16"><path d="M7 6l-4 4 4 4M13 6l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
48
|
+
</span>
|
|
49
|
+
</span>
|
|
50
|
+
<input type="range" class="ma-imgcmp__range" min="0" max="100" step="0.5" value={start} aria-label={label} />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<style is:global>
|
|
54
|
+
@layer components {
|
|
55
|
+
:where(.ma-imgcmp) {
|
|
56
|
+
position: relative;
|
|
57
|
+
display: grid;
|
|
58
|
+
overflow: clip;
|
|
59
|
+
border-radius: 0.75rem;
|
|
60
|
+
background: var(--ma-edge);
|
|
61
|
+
user-select: none;
|
|
62
|
+
-webkit-user-select: none;
|
|
63
|
+
}
|
|
64
|
+
:where(.ma-imgcmp__before),
|
|
65
|
+
:where(.ma-imgcmp__after) {
|
|
66
|
+
grid-area: 1 / 1;
|
|
67
|
+
display: block;
|
|
68
|
+
width: 100%;
|
|
69
|
+
height: auto;
|
|
70
|
+
object-fit: cover;
|
|
71
|
+
}
|
|
72
|
+
/* the after image shows from the line to the right; while the thumb is held the clip follows with no lag */
|
|
73
|
+
:where(.ma-imgcmp__after) {
|
|
74
|
+
clip-path: inset(0 0 0 var(--ma-imgcmp, 50%));
|
|
75
|
+
transition: clip-path var(--ma-duration-fast) var(--ma-ease-out);
|
|
76
|
+
}
|
|
77
|
+
:where(.ma-imgcmp__handle) {
|
|
78
|
+
position: absolute;
|
|
79
|
+
top: 0;
|
|
80
|
+
bottom: 0;
|
|
81
|
+
left: var(--ma-imgcmp, 50%);
|
|
82
|
+
display: grid;
|
|
83
|
+
place-items: center;
|
|
84
|
+
width: 2px;
|
|
85
|
+
background: var(--ma-panel);
|
|
86
|
+
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.15);
|
|
87
|
+
translate: -50% 0;
|
|
88
|
+
pointer-events: none;
|
|
89
|
+
transition: left var(--ma-duration-fast) var(--ma-ease-out);
|
|
90
|
+
}
|
|
91
|
+
:where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__after),
|
|
92
|
+
:where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__handle) {
|
|
93
|
+
transition: none;
|
|
94
|
+
}
|
|
95
|
+
:where(.ma-imgcmp__knob) {
|
|
96
|
+
display: grid;
|
|
97
|
+
place-items: center;
|
|
98
|
+
width: 2.5rem;
|
|
99
|
+
height: 2.5rem;
|
|
100
|
+
border: 1px solid var(--ma-edge);
|
|
101
|
+
border-radius: 999px;
|
|
102
|
+
background: var(--ma-panel);
|
|
103
|
+
color: var(--ma-ink);
|
|
104
|
+
box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.5);
|
|
105
|
+
transition: scale var(--ma-duration) var(--ma-ease), box-shadow var(--ma-duration-fast) var(--ma-ease-out);
|
|
106
|
+
}
|
|
107
|
+
:where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__knob),
|
|
108
|
+
:where(.ma-imgcmp:has(.ma-imgcmp__range:hover) .ma-imgcmp__knob) {
|
|
109
|
+
scale: 1.08;
|
|
110
|
+
}
|
|
111
|
+
:where(.ma-imgcmp:has(.ma-imgcmp__range:focus-visible) .ma-imgcmp__knob) {
|
|
112
|
+
box-shadow: 0 0 0 3px var(--ma-ink);
|
|
113
|
+
}
|
|
114
|
+
/* the real control, invisible over the whole box: drag, click, keys, touch and the screen reader are native */
|
|
115
|
+
:where(.ma-imgcmp__range) {
|
|
116
|
+
position: absolute;
|
|
117
|
+
inset: 0;
|
|
118
|
+
width: 100%;
|
|
119
|
+
height: 100%;
|
|
120
|
+
margin: 0;
|
|
121
|
+
opacity: 0;
|
|
122
|
+
cursor: ew-resize;
|
|
123
|
+
touch-action: pan-y;
|
|
124
|
+
}
|
|
125
|
+
:where(.ma-imgcmp__label) {
|
|
126
|
+
position: absolute;
|
|
127
|
+
top: 0.75rem;
|
|
128
|
+
padding: 0.25em 0.6em;
|
|
129
|
+
border-radius: 999px;
|
|
130
|
+
background: var(--ma-panel);
|
|
131
|
+
color: var(--ma-ink);
|
|
132
|
+
font-family: ui-monospace, monospace;
|
|
133
|
+
font-size: 0.6875rem;
|
|
134
|
+
letter-spacing: 0.06em;
|
|
135
|
+
text-transform: uppercase;
|
|
136
|
+
pointer-events: none;
|
|
137
|
+
}
|
|
138
|
+
:where(.ma-imgcmp__label--a) {
|
|
139
|
+
inset-inline-start: 0.75rem;
|
|
140
|
+
}
|
|
141
|
+
:where(.ma-imgcmp__label--b) {
|
|
142
|
+
inset-inline-end: 0.75rem;
|
|
143
|
+
}
|
|
144
|
+
/* right to left: a horizontal range runs from the right, so the value is measured from the right edge and "after" shows to
|
|
145
|
+
the left of the line (inset() and translate have no logical form). [dir] and not :dir(): Chrome has :dir() only
|
|
146
|
+
from 120, and :where() drops it silently before that. An ltr island one level inside an rtl page is set back */
|
|
147
|
+
:where([dir='rtl'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__after, .ma-imgcmp[dir='rtl']:not([data-orientation='vertical']) .ma-imgcmp__after) {
|
|
148
|
+
clip-path: inset(0 var(--ma-imgcmp, 50%) 0 0);
|
|
149
|
+
}
|
|
150
|
+
:where([dir='rtl'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__handle, .ma-imgcmp[dir='rtl']:not([data-orientation='vertical']) .ma-imgcmp__handle) {
|
|
151
|
+
left: auto;
|
|
152
|
+
right: var(--ma-imgcmp, 50%);
|
|
153
|
+
translate: 50% 0;
|
|
154
|
+
transition-property: right;
|
|
155
|
+
}
|
|
156
|
+
:where([dir='rtl'] [dir='ltr'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__after, [dir='rtl'] .ma-imgcmp[dir='ltr']:not([data-orientation='vertical']) .ma-imgcmp__after) {
|
|
157
|
+
clip-path: inset(0 0 0 var(--ma-imgcmp, 50%));
|
|
158
|
+
}
|
|
159
|
+
:where([dir='rtl'] [dir='ltr'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__handle, [dir='rtl'] .ma-imgcmp[dir='ltr']:not([data-orientation='vertical']) .ma-imgcmp__handle) {
|
|
160
|
+
left: var(--ma-imgcmp, 50%);
|
|
161
|
+
right: auto;
|
|
162
|
+
translate: -50% 0;
|
|
163
|
+
transition-property: left;
|
|
164
|
+
}
|
|
165
|
+
/* vertical: the line runs across; the range is written top to bottom */
|
|
166
|
+
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__after) {
|
|
167
|
+
clip-path: inset(var(--ma-imgcmp, 50%) 0 0 0);
|
|
168
|
+
}
|
|
169
|
+
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__handle) {
|
|
170
|
+
top: var(--ma-imgcmp, 50%);
|
|
171
|
+
bottom: auto;
|
|
172
|
+
left: 0;
|
|
173
|
+
right: 0;
|
|
174
|
+
width: auto;
|
|
175
|
+
height: 2px;
|
|
176
|
+
translate: 0 -50%;
|
|
177
|
+
transition: top var(--ma-duration-fast) var(--ma-ease-out);
|
|
178
|
+
}
|
|
179
|
+
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__knob svg) {
|
|
180
|
+
rotate: 90deg;
|
|
181
|
+
}
|
|
182
|
+
/* direction: ltr keeps the vertical range top to bottom on a right-to-left page too (rtl would run it bottom to top) */
|
|
183
|
+
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__range) {
|
|
184
|
+
writing-mode: vertical-lr;
|
|
185
|
+
direction: ltr;
|
|
186
|
+
cursor: ns-resize;
|
|
187
|
+
touch-action: pan-x;
|
|
188
|
+
}
|
|
189
|
+
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__label--b) {
|
|
190
|
+
top: auto;
|
|
191
|
+
bottom: 0.75rem;
|
|
192
|
+
inset-inline-end: auto;
|
|
193
|
+
inset-inline-start: 0.75rem;
|
|
194
|
+
}
|
|
195
|
+
@media (prefers-reduced-motion: reduce) {
|
|
196
|
+
:where(.ma-imgcmp__after),
|
|
197
|
+
:where(.ma-imgcmp__handle),
|
|
198
|
+
:where(.ma-imgcmp__knob) {
|
|
199
|
+
transition: none;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
204
|
+
|
|
205
|
+
<script>
|
|
206
|
+
import { onMount } from '../lib/runtime';
|
|
207
|
+
|
|
208
|
+
onMount<HTMLElement>('[data-ma-imgcmp]', (el, { signal }) => {
|
|
209
|
+
const range = el.querySelector('input')!;
|
|
210
|
+
range.addEventListener('input', () => el.style.setProperty('--ma-imgcmp', `${range.value}%`), { signal });
|
|
211
|
+
});
|
|
212
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'ImageCompare',
|
|
5
|
+
slug: 'image-compare',
|
|
6
|
+
title: 'Image Compare',
|
|
7
|
+
description: 'Compares a before and an after image with a line you drag between them. A native range input lies over the box, invisible, so dragging, clicking to jump, the arrow keys, touch and the screen reader are the browser\'s; the script writes its value to one custom property and the after image is clipped to it. Held, it follows with no lag; stepped, it glides on the fast token.',
|
|
8
|
+
summary: 'A before and after image slider on a native range input: drag, click, arrow keys and touch all work.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['pointer', 'click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['clip-path', 'has', 'range-vertical'], fallback: 'without :has() the line glides while held too; without vertical ranges, orientation="vertical" keeps a horizontal range over the box, so its line follows a sideways drag' },
|
|
13
|
+
readout: '<ImageCompare start={50}>',
|
|
14
|
+
usage: `<ImageCompare\n before="/work/sketch.jpg"\n after="/work/final.jpg"\n beforeAlt="Pencil sketch of the poster"\n afterAlt="The printed poster"\n start={40}\n/>\n\n<ImageCompare before={a} after={b} orientation="vertical" labels={['Day', 'Night']} />`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'before', type: 'string', description: 'The image on the inline start (the left, or the right on a right-to-left page), or the top.' },
|
|
17
|
+
{ name: 'after', type: 'string', description: 'The image on the inline end, or the bottom.' },
|
|
18
|
+
{ name: 'beforeAlt', type: 'string', default: "''", description: 'Alt text of the before image.' },
|
|
19
|
+
{ name: 'afterAlt', type: 'string', default: "''", description: 'Alt text of the after image.' },
|
|
20
|
+
{ name: 'start', type: 'number', default: '50', description: 'Initial position of the line, 0–100. Without JavaScript the line stands here.' },
|
|
21
|
+
{ name: 'orientation', type: "'horizontal' | 'vertical'", default: "'horizontal'", description: 'Which way the line runs; vertical writes the range top to bottom.' },
|
|
22
|
+
{ name: 'labels', type: '[string, string] | false', default: "['Before', 'After']", description: 'Corner labels.' },
|
|
23
|
+
{ name: 'label', type: 'string', default: "'Compare'", description: 'Accessible name of the slider.' },
|
|
24
|
+
],
|
|
25
|
+
reducedMotion: 'No gliding: the line moves at once on a key or a click too.',
|
|
26
|
+
clientRouter: 'Bound through the shared runtime; one input listener per instance, aborted before the swap.',
|
|
27
|
+
craft: [
|
|
28
|
+
'The control is a real range input over the whole box: keyboard, screen reader ("Compare, slider, 50 %"), touch and click-to-jump come with it, and the drag needs no pointer capture of its own.',
|
|
29
|
+
'The script is one line (the value into a custom property), and the geometry is CSS: clip-path: inset() on the after image and the handle\'s inset read the same property.',
|
|
30
|
+
'Held, the clip must not lag: :has(input:active) removes the transition while the thumb is down and restores it for a keyboard step or a click, which then glide on the fast token.',
|
|
31
|
+
'Both images are laid in one grid cell so the box is the size of the picture and the two can never drift apart; clip-path costs no layout.',
|
|
32
|
+
'touch-action: pan-y on the horizontal range: a sideways drag moves the line, a vertical one scrolls the page.',
|
|
33
|
+
'Right to left, the native range runs from the right, so the clip and the handle are measured from the right under a dir="rtl" ancestor and the line stays under the pointer; the vertical range is pinned to direction: ltr so it always runs top to bottom.',
|
|
34
|
+
],
|
|
35
|
+
related: ['lightbox', 'scroll-expand', 'drawer'],
|
|
36
|
+
intents: ['before after slider', 'image comparison slider', 'compare two images drag', 'before after image astro', 'juxtapose images', 'split image reveal'],
|
|
37
|
+
replaces: ['Compare (Aceternity)', 'ImageComparison (Motion Primitives)', 'react-compare-image', 'Juxtapose'],
|
|
38
|
+
fixture: { props: { before: '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', after: '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', label: 'Fixture content for ImageCompare' }, expect: 'Fixture content for ImageCompare' },
|
|
39
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ImageTrail — small images pop up along the pointer's path and fade away
|
|
4
|
+
* behind it. The images are a server-rendered pool (one <img> each, lazy,
|
|
5
|
+
* hidden); the script only writes a position and restarts one keyframe on
|
|
6
|
+
* the next image in the pool every time the pointer has travelled
|
|
7
|
+
* `threshold` px, so nothing is created on move (a move reads the box's
|
|
8
|
+
* position, a restart one offsetWidth). The pop and the fade are CSS on
|
|
9
|
+
* the preset's curve, the lifetime is a duration token. Only listens
|
|
10
|
+
* inside its own box. On touch and coarse pointers the script never binds,
|
|
11
|
+
* under reduced motion (read on every move) it spawns nothing, and the
|
|
12
|
+
* first image sits still in the centre, under the slotted content — the
|
|
13
|
+
* same thing the HTML shows without JavaScript.
|
|
14
|
+
*/
|
|
15
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
16
|
+
|
|
17
|
+
interface Image {
|
|
18
|
+
src: string;
|
|
19
|
+
alt?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
23
|
+
/** Element to render. */
|
|
24
|
+
as?: HTMLTag;
|
|
25
|
+
/** Up to 12 images (URLs or { src, alt }); the trail cycles through them. */
|
|
26
|
+
images: (string | Image)[];
|
|
27
|
+
/** Pointer travel in px between two images. */
|
|
28
|
+
threshold?: number;
|
|
29
|
+
/** How long an image stays: a duration token, not milliseconds. */
|
|
30
|
+
lifetime?: 'fast' | 'base' | 'slow';
|
|
31
|
+
/** Image width in px; height follows the image. */
|
|
32
|
+
size?: number;
|
|
33
|
+
/** Maximum tilt in degrees; each image leans with the direction the pointer was moving. */
|
|
34
|
+
tilt?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { as: Tag = 'div', images, threshold = 80, lifetime = 'base', size = 140, tilt = 8, class: className, style, ...rest } = Astro.props;
|
|
38
|
+
const pool: Image[] = images.slice(0, 12).map((i) => (typeof i === 'string' ? { src: i } : i));
|
|
39
|
+
const inline = [`--ma-trail-threshold:${threshold}`, `--ma-trail-size:${size}px`, `--ma-trail-tilt:${tilt}deg`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<Tag class:list={['ma-trail', className]} data-ma-trail data-life={lifetime} style={inline} {...rest}>
|
|
43
|
+
<slot />
|
|
44
|
+
<span class="ma-trail__pool" aria-hidden="true">
|
|
45
|
+
{pool.map((img, i) => <img src={img.src} alt={img.alt ?? ''} loading="lazy" decoding="async" draggable="false" style={`--ma-i:${i}`} />)}
|
|
46
|
+
</span>
|
|
47
|
+
</Tag>
|
|
48
|
+
|
|
49
|
+
<style is:global>
|
|
50
|
+
@layer components {
|
|
51
|
+
:where(.ma-trail) {
|
|
52
|
+
position: relative;
|
|
53
|
+
isolation: isolate;
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
/* the lifetime is a token; the pop is the preset's own curve */
|
|
56
|
+
--ma-trail-life: var(--ma-dur-ambient);
|
|
57
|
+
}
|
|
58
|
+
:where(.ma-trail[data-life='fast']) {
|
|
59
|
+
--ma-trail-life: var(--ma-duration);
|
|
60
|
+
}
|
|
61
|
+
:where(.ma-trail[data-life='slow']) {
|
|
62
|
+
--ma-trail-life: calc(var(--ma-dur-ambient) * 2);
|
|
63
|
+
}
|
|
64
|
+
:where(.ma-trail__pool) {
|
|
65
|
+
position: absolute;
|
|
66
|
+
inset: 0;
|
|
67
|
+
pointer-events: none;
|
|
68
|
+
z-index: 1;
|
|
69
|
+
/* the pool is the container the resting image is centred in (cqw / cqh below) */
|
|
70
|
+
container-type: size;
|
|
71
|
+
}
|
|
72
|
+
:where(.ma-trail__pool img) {
|
|
73
|
+
position: absolute;
|
|
74
|
+
left: 0;
|
|
75
|
+
top: 0;
|
|
76
|
+
width: var(--ma-trail-size, 140px);
|
|
77
|
+
height: auto;
|
|
78
|
+
border-radius: 0.5rem;
|
|
79
|
+
object-fit: cover;
|
|
80
|
+
opacity: 0;
|
|
81
|
+
/* --ma-trail-x / --ma-trail-y: the spawn point; --ma-trail-r: the lean, both written by the script */
|
|
82
|
+
translate: calc(var(--ma-trail-x, 50cqw) - 50%) calc(var(--ma-trail-y, 50cqh) - 50%);
|
|
83
|
+
rotate: var(--ma-trail-r, 0deg);
|
|
84
|
+
will-change: opacity, scale;
|
|
85
|
+
}
|
|
86
|
+
:where(.ma-trail__pool img[data-live]) {
|
|
87
|
+
animation: ma-trail var(--ma-trail-life) var(--ma-ease-out) both;
|
|
88
|
+
}
|
|
89
|
+
/* no script (or none allowed): the first image, still, in the centre — under the slotted content, which it would cover */
|
|
90
|
+
:where(html:not([data-ma-js]) .ma-trail__pool) {
|
|
91
|
+
z-index: -1;
|
|
92
|
+
}
|
|
93
|
+
:where(html:not([data-ma-js]) .ma-trail__pool img:first-child) {
|
|
94
|
+
opacity: 1;
|
|
95
|
+
}
|
|
96
|
+
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
|
|
97
|
+
:where(.ma-trail__pool) {
|
|
98
|
+
z-index: -1;
|
|
99
|
+
}
|
|
100
|
+
:where(.ma-trail__pool img) {
|
|
101
|
+
animation: none;
|
|
102
|
+
opacity: 0;
|
|
103
|
+
}
|
|
104
|
+
/* centred and upright even when the trail has used it: reduced motion can be switched on after a few spawns */
|
|
105
|
+
:where(.ma-trail__pool img:first-child) {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
translate: calc(50cqw - 50%) calc(50cqh - 50%);
|
|
108
|
+
rotate: 0deg;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@keyframes ma-trail {
|
|
114
|
+
0% {
|
|
115
|
+
opacity: 0;
|
|
116
|
+
scale: 0.6;
|
|
117
|
+
}
|
|
118
|
+
18% {
|
|
119
|
+
opacity: 1;
|
|
120
|
+
scale: 1;
|
|
121
|
+
}
|
|
122
|
+
70% {
|
|
123
|
+
opacity: 1;
|
|
124
|
+
scale: 1;
|
|
125
|
+
}
|
|
126
|
+
100% {
|
|
127
|
+
opacity: 0;
|
|
128
|
+
scale: 1.04;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
132
|
+
|
|
133
|
+
<script>
|
|
134
|
+
import { onMount, prefersReducedMotion } from '../lib/runtime';
|
|
135
|
+
|
|
136
|
+
onMount<HTMLElement>('[data-ma-trail]', (el, { signal }) => {
|
|
137
|
+
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
|
138
|
+
const imgs = el.querySelectorAll<HTMLElement>('.ma-trail__pool img');
|
|
139
|
+
if (!imgs.length) return;
|
|
140
|
+
const threshold = Number(el.style.getPropertyValue('--ma-trail-threshold')) || 80;
|
|
141
|
+
let lx = -1e9;
|
|
142
|
+
let ly = 0;
|
|
143
|
+
let n = 0;
|
|
144
|
+
el.addEventListener(
|
|
145
|
+
'pointermove',
|
|
146
|
+
(e) => {
|
|
147
|
+
// read on every move, so the page's motion switch applies both ways without a reload
|
|
148
|
+
if (prefersReducedMotion()) return;
|
|
149
|
+
const r = el.getBoundingClientRect();
|
|
150
|
+
const x = e.clientX - r.left;
|
|
151
|
+
const y = e.clientY - r.top;
|
|
152
|
+
const dx = x - lx;
|
|
153
|
+
const dy = y - ly;
|
|
154
|
+
if (dx * dx + dy * dy < threshold * threshold) return;
|
|
155
|
+
lx = x;
|
|
156
|
+
ly = y;
|
|
157
|
+
const img = imgs[n++ % imgs.length]!;
|
|
158
|
+
img.removeAttribute('data-live');
|
|
159
|
+
void img.offsetWidth; // restart the keyframes on a recycled image
|
|
160
|
+
img.style.setProperty('--ma-trail-x', `${x}px`);
|
|
161
|
+
img.style.setProperty('--ma-trail-y', `${y}px`);
|
|
162
|
+
img.style.setProperty('--ma-trail-r', `calc(${Math.max(-1, Math.min(1, dx / threshold)).toFixed(2)} * var(--ma-trail-tilt))`);
|
|
163
|
+
img.setAttribute('data-live', '');
|
|
164
|
+
},
|
|
165
|
+
{ signal, passive: true },
|
|
166
|
+
);
|
|
167
|
+
el.addEventListener('animationend', (e) => (e.target as HTMLElement).removeAttribute('data-live'), { signal });
|
|
168
|
+
});
|
|
169
|
+
</script>
|