@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,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* LiquidGlass — a pane of glass over whatever is behind it, zero JS. In
|
|
4
|
+
* every engine: `backdrop-filter: blur() saturate()`, a specular rim from
|
|
5
|
+
* two inset highlights, a soft tint from the tokens, and a sheen that
|
|
6
|
+
* slides across on :hover / :focus-within (CSS, so the trigger is hover,
|
|
7
|
+
* not the pointer's position). The refraction — the backdrop bending at
|
|
8
|
+
* the rim — is an SVG displacement map on a pseudo-element with
|
|
9
|
+
* `backdrop-filter: url(#…)`: the map is built on the server from
|
|
10
|
+
* `strength` and `edge` as two gradients (red bends x, green bends y)
|
|
11
|
+
* composited in the filter, stretched to the element's box by the filter
|
|
12
|
+
* itself, so no size is ever measured and any size works. Chromium renders
|
|
13
|
+
* it; Firefox and WebKit accept the declaration and paint nothing on that
|
|
14
|
+
* layer (measured), so they show the same glass without the bend.
|
|
15
|
+
*/
|
|
16
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
17
|
+
|
|
18
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
19
|
+
/** Displacement at the rim, in px (Chromium). 0 removes the layer. */
|
|
20
|
+
strength?: number;
|
|
21
|
+
/** How far the bend reaches in from the edge, as a fraction of the box. */
|
|
22
|
+
edge?: number;
|
|
23
|
+
/** Backdrop blur radius, any CSS length. */
|
|
24
|
+
blur?: string;
|
|
25
|
+
/** Tint opacity of the pane, 0–1; default leans on the theme. */
|
|
26
|
+
tint?: number;
|
|
27
|
+
/** Corner radius, any CSS length. */
|
|
28
|
+
radius?: string;
|
|
29
|
+
/** Filter id; unique per pane when omitted. */
|
|
30
|
+
id?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { strength = 40, edge = 0.18, blur = '10px', tint, radius = '1.5rem', id, class: className, style, ...rest } = Astro.props;
|
|
34
|
+
const e = Math.min(0.5, Math.max(0.02, edge));
|
|
35
|
+
// one filter per pane: two panes with the same props used to render two filters under one id, which a page may not have
|
|
36
|
+
const filterId = id ?? `ma-glass-${Math.round(strength)}-${Math.round(e * 100)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
37
|
+
// one axis of the map: neutral (128) through the middle, a smooth ramp to 0 and 255 across the edge band
|
|
38
|
+
const ramp = (axis: 'x' | 'y') => {
|
|
39
|
+
const c = (v: number) => (axis === 'x' ? `rgb(${v},128,128)` : `rgb(128,${v},128)`);
|
|
40
|
+
// an ease across the band (four stops a side), so the bend fades in with no seam where the map goes neutral
|
|
41
|
+
const stops = [
|
|
42
|
+
[0, 0],
|
|
43
|
+
[e * 0.25, 48],
|
|
44
|
+
[e * 0.55, 96],
|
|
45
|
+
[e * 0.8, 118],
|
|
46
|
+
[e, 128],
|
|
47
|
+
[1 - e, 128],
|
|
48
|
+
[1 - e * 0.8, 138],
|
|
49
|
+
[1 - e * 0.55, 160],
|
|
50
|
+
[1 - e * 0.25, 208],
|
|
51
|
+
[1, 255],
|
|
52
|
+
]
|
|
53
|
+
.map(([o, v]) => `<stop offset='${o}' stop-color='${c(v!)}'/>`)
|
|
54
|
+
.join('');
|
|
55
|
+
const dir = axis === 'x' ? "x1='0' x2='1' y1='0' y2='0'" : "x1='0' x2='0' y1='0' y2='1'";
|
|
56
|
+
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'><linearGradient id='g' ${dir}>${stops}</linearGradient><rect width='100' height='100' fill='url(#g)'/></svg>`)}`;
|
|
57
|
+
};
|
|
58
|
+
const vars = [`--ma-glass-blur:${blur}`, `--ma-glass-r:${radius}`, tint !== undefined && `--ma-glass-tint:${tint}`, `--ma-glass-filter:url(#${filterId})`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
<div class:list={['ma-glass', className]} data-refract={strength > 0 ? '' : undefined} style={vars} {...rest}>
|
|
62
|
+
{strength > 0 && (
|
|
63
|
+
<svg class="ma-glass__map" width="0" height="0" aria-hidden="true" focusable="false">
|
|
64
|
+
<filter id={filterId} x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
|
65
|
+
<feImage href={ramp('x')} result="mx" preserveAspectRatio="none" x="0" y="0" width="100%" height="100%" />
|
|
66
|
+
<feImage href={ramp('y')} result="my" preserveAspectRatio="none" x="0" y="0" width="100%" height="100%" />
|
|
67
|
+
<feComposite in="mx" in2="my" operator="arithmetic" k1="0" k2="0.5" k3="0.5" k4="0" result="map" />
|
|
68
|
+
<feDisplacementMap in="SourceGraphic" in2="map" scale={strength * 2} xChannelSelector="R" yChannelSelector="G" />
|
|
69
|
+
</filter>
|
|
70
|
+
</svg>
|
|
71
|
+
)}
|
|
72
|
+
<div class="ma-glass__body"><slot /></div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<style is:global>
|
|
76
|
+
@layer components {
|
|
77
|
+
:where(.ma-glass) {
|
|
78
|
+
/* the tint leans lighter on a light ground, darker on a dark one, from the theme scalar */
|
|
79
|
+
--ma-glass-tint: calc(0.22 - var(--ma-dark) * 0.1);
|
|
80
|
+
position: relative;
|
|
81
|
+
isolation: isolate;
|
|
82
|
+
border-radius: var(--ma-glass-r, 1.5rem);
|
|
83
|
+
background: color-mix(in srgb, var(--ma-panel) calc(var(--ma-glass-tint) * 100%), transparent);
|
|
84
|
+
color: inherit;
|
|
85
|
+
box-shadow:
|
|
86
|
+
inset 0 0 0 1px color-mix(in srgb, var(--ma-shine) 55%, transparent),
|
|
87
|
+
inset 0 1px 0 var(--ma-shine),
|
|
88
|
+
inset 0 -1px 0 color-mix(in srgb, var(--ma-shine) 25%, transparent),
|
|
89
|
+
0 24px 48px -24px rgb(0 0 0 / 0.45);
|
|
90
|
+
backdrop-filter: blur(var(--ma-glass-blur, 10px)) saturate(1.5);
|
|
91
|
+
overflow: clip;
|
|
92
|
+
}
|
|
93
|
+
/* the rim refraction: its own layer, so an engine that paints nothing for an SVG backdrop filter still shows the glass */
|
|
94
|
+
:where(.ma-glass[data-refract])::before {
|
|
95
|
+
content: '';
|
|
96
|
+
position: absolute;
|
|
97
|
+
inset: 0;
|
|
98
|
+
z-index: -1;
|
|
99
|
+
border-radius: inherit;
|
|
100
|
+
backdrop-filter: var(--ma-glass-filter);
|
|
101
|
+
pointer-events: none;
|
|
102
|
+
}
|
|
103
|
+
/* the sheen: a diagonal band parked off the left edge, crossing on hover */
|
|
104
|
+
:where(.ma-glass)::after {
|
|
105
|
+
content: '';
|
|
106
|
+
position: absolute;
|
|
107
|
+
inset: -20% -60%;
|
|
108
|
+
z-index: -1;
|
|
109
|
+
background: linear-gradient(115deg, transparent 40%, color-mix(in srgb, var(--ma-shine) 60%, transparent) 50%, transparent 60%);
|
|
110
|
+
translate: -70% 0;
|
|
111
|
+
opacity: 0;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
transition:
|
|
114
|
+
translate var(--ma-duration-slow) var(--ma-ease-out),
|
|
115
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
116
|
+
}
|
|
117
|
+
@media (hover: hover) and (pointer: fine) {
|
|
118
|
+
:where(.ma-glass:hover)::after,
|
|
119
|
+
:where(.ma-glass:focus-within)::after {
|
|
120
|
+
translate: 70% 0;
|
|
121
|
+
opacity: 1;
|
|
122
|
+
transition:
|
|
123
|
+
translate var(--ma-duration-slow) var(--ma-ease-in-out),
|
|
124
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
:where(.ma-glass__map) {
|
|
128
|
+
position: absolute;
|
|
129
|
+
}
|
|
130
|
+
:where(.ma-glass__body) {
|
|
131
|
+
position: relative;
|
|
132
|
+
z-index: 1;
|
|
133
|
+
}
|
|
134
|
+
@media (forced-colors: active) {
|
|
135
|
+
:where(.ma-glass) {
|
|
136
|
+
border: 1px solid CanvasText;
|
|
137
|
+
background: Canvas;
|
|
138
|
+
backdrop-filter: none;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
@media (prefers-reduced-motion: reduce) {
|
|
142
|
+
:where(.ma-glass)::after {
|
|
143
|
+
transition: none;
|
|
144
|
+
display: none;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'LiquidGlass',
|
|
5
|
+
slug: 'liquid-glass',
|
|
6
|
+
title: 'Liquid Glass',
|
|
7
|
+
description: 'A glass pane that blurs and tints whatever is behind it. Every engine: backdrop blur and saturation, a specular rim, a tint from the tokens, a sheen that crosses on hover. The refraction at the rim is an SVG displacement map on its own backdrop-filter layer, built on the server from strength and edge and stretched to the box by the filter, so it works at any size with no measuring. Chromium bends the backdrop; Firefox and WebKit paint nothing on that layer and show the same glass without the bend. Zero JavaScript.',
|
|
8
|
+
summary: 'A liquid glass pane that blurs and tints what is behind it, with a refracting rim in Chromium.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['hover'],
|
|
12
|
+
support: { baseline: 'limited', needs: ['backdrop-filter', 'backdrop-svg'], fallback: 'Firefox and Safari show the glass (blur, saturation, rim, tint, sheen) without the rim refraction; the displacement layer paints nothing there (measured, 0 pixels differ)' },
|
|
13
|
+
readout: '<LiquidGlass strength={40} blur="10px">',
|
|
14
|
+
usage: `<div class="relative">\n <img src={photo.src} alt="" class="w-full" />\n <LiquidGlass class="absolute inset-x-8 bottom-8 p-6" strength={40} radius="2rem">\n <h3>Glass over the photo</h3>\n <p>Blur, a rim and a sheen on hover. In Chromium the edge also bends the picture.</p>\n </LiquidGlass>\n</div>\n\n<!-- a pill, no refraction -->\n<LiquidGlass strength={0} radius="999px" class="px-5 py-2">Pill</LiquidGlass>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'strength', type: 'number', default: '40', description: 'Displacement at the rim in px (Chromium). 0 leaves the refraction layer out entirely.' },
|
|
17
|
+
{ name: 'edge', type: 'number', default: '0.18', description: 'How far the bend reaches in from each edge, as a fraction of the box (0.02–0.5).' },
|
|
18
|
+
{ name: 'blur', type: 'string', default: "'10px'", description: 'Backdrop blur radius.' },
|
|
19
|
+
{ name: 'tint', type: 'number', description: 'Opacity of the panel tint, 0–1; by default 0.22 on a light ground and 0.12 on a dark one (the --ma-dark scalar).' },
|
|
20
|
+
{ name: 'radius', type: 'string', default: "'1.5rem'", description: 'Corner radius; the rim and the refraction follow it.' },
|
|
21
|
+
{ name: 'id', type: 'string', default: 'unique per pane', description: 'The filter id. Omitted, every pane gets its own, so ids never repeat on a page.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'No sheen; the glass, the rim and the refraction are static and stay.',
|
|
24
|
+
clientRouter: 'Static; nothing to bind. The inline SVG filter is part of the page and swaps with it.',
|
|
25
|
+
craft: [
|
|
26
|
+
'The refraction was prototyped first, as the handoff asked (docs/phase-7e.md): backdrop-filter: url(#filter) with feDisplacementMap renders in Chromium only. Firefox 155 and WebKit 26.6 accept the declaration in computed style and paint nothing. This was measured on a probe page with the layer on and off: 0 pixels differ. So the refraction lives on a pseudo-element of its own, and the glass every engine sees is the element\'s own backdrop-filter, never invalidated by a url() it cannot paint.',
|
|
27
|
+
'The displacement map is two gradients, red for x and green for y, neutral through the middle and ramping across the edge band, composited to one map inside the filter (feComposite arithmetic) and stretched to the box by feImage at 100 %, so a pill, a card and a full-width bar all get a rim bend with the same map and nothing measured in the DOM.',
|
|
28
|
+
'The rim is three inset shadows in the shine token (a hairline all round, a bright top edge, a fainter bottom one), so the pane has a thickness. The tint leans on --ma-dark: a whiter pane on a light page, a darker one on a dark page, from one calc().',
|
|
29
|
+
'The sheen is a diagonal band parked off the left edge that crosses on :hover and :focus-within, on the slow duration with the in-out curve; behind (hover: hover), so on touch nothing sticks.',
|
|
30
|
+
'Text on the pane should be the full foreground colour: measured on the stage over a bright scene, the site\'s muted text token fell to 2.4 : 1 while the foreground held 6 : 1 and above in both themes. The tint is too faint to act as a surface for text.',
|
|
31
|
+
'Windows high contrast drops the filters and draws a Canvas panel with a CanvasText border, which stays legible.',
|
|
32
|
+
],
|
|
33
|
+
related: ['progressive-blur', 'tilt', 'spotlight'],
|
|
34
|
+
intents: ['liquid glass', 'glassmorphism css', 'frosted glass panel', 'glass card with refraction', 'apple liquid glass effect', 'backdrop blur card'],
|
|
35
|
+
replaces: ['LiquidGlass (React Bits)', 'Glass card (Kokonut UI)', 'liquid-glass-react'],
|
|
36
|
+
fixture: { props: { strength: 40 }, slot: '<p>Fixture content for LiquidGlass</p>', expect: 'Fixture content for LiquidGlass' },
|
|
37
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Magnetic — the child is pulled toward the cursor inside a radius and
|
|
4
|
+
* springs back when the cursor leaves.
|
|
5
|
+
*
|
|
6
|
+
* The wrapper is the field (its padding is the radius) but lets every
|
|
7
|
+
* pointer event through, so it never covers a neighbour's click; one
|
|
8
|
+
* pointermove listener on the document measures the pointer against the
|
|
9
|
+
* field and writes the pull as two custom properties that the child's
|
|
10
|
+
* translate reads. Following is near-instant; returning uses the active
|
|
11
|
+
* preset's spring. Reduced motion is read on every move: no pull while it
|
|
12
|
+
* is on, the pull again as soon as it is off.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
15
|
+
|
|
16
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
17
|
+
/** Element to render. */
|
|
18
|
+
as?: HTMLTag;
|
|
19
|
+
/** Pull as a fraction of the cursor's offset from the centre, 0–1. */
|
|
20
|
+
strength?: number;
|
|
21
|
+
/** Field radius around the child in px. */
|
|
22
|
+
radius?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { as: Tag = 'span', strength = 0.4, radius = 40, class: className, style, ...rest } = Astro.props;
|
|
26
|
+
const inline = [`--ma-mg-strength:${strength}`, `--ma-mg-radius:${radius}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<Tag class:list={['ma-magnetic', className]} data-ma-magnetic style={inline} {...rest}>
|
|
30
|
+
<slot />
|
|
31
|
+
</Tag>
|
|
32
|
+
|
|
33
|
+
<style is:global>
|
|
34
|
+
@layer components {
|
|
35
|
+
/* the field reaches over its neighbours, so it must not catch their pointer: only the child does */
|
|
36
|
+
:where(.ma-magnetic) {
|
|
37
|
+
display: inline-block;
|
|
38
|
+
padding: var(--ma-mg-radius, 40px);
|
|
39
|
+
margin: calc(var(--ma-mg-radius, 40px) * -1);
|
|
40
|
+
pointer-events: none;
|
|
41
|
+
}
|
|
42
|
+
:where(.ma-magnetic > *) {
|
|
43
|
+
display: inline-block;
|
|
44
|
+
pointer-events: auto;
|
|
45
|
+
translate: var(--ma-mx, 0px) var(--ma-my, 0px);
|
|
46
|
+
transition: translate var(--ma-duration) var(--ma-ease);
|
|
47
|
+
}
|
|
48
|
+
:where(.ma-magnetic[data-moving] > *) {
|
|
49
|
+
transition-duration: 120ms;
|
|
50
|
+
transition-timing-function: linear;
|
|
51
|
+
}
|
|
52
|
+
@media (prefers-reduced-motion: reduce) {
|
|
53
|
+
:where(.ma-magnetic > *) {
|
|
54
|
+
translate: 0 0;
|
|
55
|
+
transition: none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
60
|
+
|
|
61
|
+
<script>
|
|
62
|
+
import { onMount, prefersReducedMotion } from '../lib/runtime';
|
|
63
|
+
|
|
64
|
+
onMount<HTMLElement>('[data-ma-magnetic]', (el, { signal }) => {
|
|
65
|
+
const strength = Number(el.style.getPropertyValue('--ma-mg-strength') || 0.4);
|
|
66
|
+
const pull = (x: number, y: number) => {
|
|
67
|
+
el.style.setProperty('--ma-mx', `${x}px`);
|
|
68
|
+
el.style.setProperty('--ma-my', `${y}px`);
|
|
69
|
+
};
|
|
70
|
+
const leave = () => {
|
|
71
|
+
delete el.dataset.moving;
|
|
72
|
+
pull(0, 0);
|
|
73
|
+
};
|
|
74
|
+
// the field lets the pointer through (it lies over the neighbours), so it is measured, not hit: the document hears the
|
|
75
|
+
// moves and the wrapper's box is the field, whatever box its parent has
|
|
76
|
+
document.addEventListener(
|
|
77
|
+
'pointermove',
|
|
78
|
+
(e) => {
|
|
79
|
+
const r = el.getBoundingClientRect();
|
|
80
|
+
const x = e.clientX - r.left;
|
|
81
|
+
const y = e.clientY - r.top;
|
|
82
|
+
// outside the field, or reduced motion switched on since the bind: spring back once
|
|
83
|
+
if (x < 0 || y < 0 || x > r.width || y > r.height || prefersReducedMotion()) return 'moving' in el.dataset && leave();
|
|
84
|
+
pull((x - r.width / 2) * strength, (y - r.height / 2) * strength);
|
|
85
|
+
el.dataset.moving = '';
|
|
86
|
+
},
|
|
87
|
+
{ signal, passive: true },
|
|
88
|
+
);
|
|
89
|
+
// the pointer left the window, a pen left its range or a touch ended: spring back
|
|
90
|
+
document.addEventListener('pointerout', (e) => e.relatedTarget || leave(), { signal });
|
|
91
|
+
});
|
|
92
|
+
</script>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Magnetic',
|
|
5
|
+
slug: 'magnetic',
|
|
6
|
+
title: 'Magnetic',
|
|
7
|
+
description: 'A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves. An invisible field around the child lets clicks through to its neighbours; one pointermove listener on the document measures the pointer against it, and the pull is CSS translate on the active preset\'s spring. No pull under reduced motion, read on every move.',
|
|
8
|
+
summary: 'A magnetic button effect: the element is pulled toward a nearby cursor and springs back when it leaves.',
|
|
9
|
+
category: 'pointer',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['pointer', 'hover'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<Magnetic strength={0.4}>',
|
|
14
|
+
usage: `<Magnetic strength={0.4} radius={48}>\n <a href="/setup/" class="rounded-full bg-fg px-5 py-3 text-bg">Get started</a>\n</Magnetic>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
17
|
+
{ name: 'strength', type: 'number', default: '0.4', description: 'Pull as a fraction of the cursor\'s offset from the centre. Over 0.6 the button escapes the cursor.' },
|
|
18
|
+
{ name: 'radius', type: 'number', default: '40', description: 'Field radius around the child in px; the pull starts when the cursor enters it.' },
|
|
19
|
+
],
|
|
20
|
+
reducedMotion: 'No pull: the child stays put in CSS and the script checks the setting on every move, so switching reduced motion on or off (the system or the page\'s own switch) applies at once.',
|
|
21
|
+
clientRouter: 'Bound through the shared runtime; listeners aborted before the swap.',
|
|
22
|
+
craft: [
|
|
23
|
+
'The field is padding with a negative margin, so the layout is unchanged and the cursor is caught before it touches the button. It has pointer-events: none (the child takes them back), so a neighbour inside the field keeps its clicks and its hover. The field is measured instead of hit: one passive pointermove listener on the document reads the wrapper\'s box, one rect read per move, and writes only while the pointer is inside.',
|
|
24
|
+
'Following: 120 ms linear, because direct manipulation must not lag. Returning: the preset spring, because release is where the character lives.',
|
|
25
|
+
'The pull is a fraction of the offset, not a fixed distance, so it is strongest at the edge of the field and the button always sits under the cursor on the way out.',
|
|
26
|
+
],
|
|
27
|
+
related: ['tilt', 'spotlight', 'cursor-grid'],
|
|
28
|
+
intents: ['magnetic button', 'button follows cursor', 'magnetic hover effect', 'cursor attraction button'],
|
|
29
|
+
replaces: ['Magnet (React Bits)', 'magnetic button (GSAP tutorials)'],
|
|
30
|
+
fixture: { expect: 'class="ma-magnetic"' },
|
|
31
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Marquee — seamless infinite strip, zero JS.
|
|
4
|
+
*
|
|
5
|
+
* The slot is rendered twice (or `repeat` times); the track translates by
|
|
6
|
+
* exactly one copy, so the loop never shows a seam. The track is at least one
|
|
7
|
+
* strip plus one copy's share wide and the copies stretch to fill it, spacing
|
|
8
|
+
* their items evenly, so a copy narrower than the strip never leaves a blank
|
|
9
|
+
* band: a percentage of the strip, nothing measured. Pauses on hover so a
|
|
10
|
+
* reader can stop a logo or a link. Reduced motion turns it into a static,
|
|
11
|
+
* scrollable strip.
|
|
12
|
+
*
|
|
13
|
+
* The strip is a tab stop (unless it is aria-hidden): focus pauses it — the
|
|
14
|
+
* keyboard's way to stop moving content (WCAG 2.2.2) — and under reduced motion
|
|
15
|
+
* the arrow keys scroll it. The extra copies are inert, so their links are
|
|
16
|
+
* never reached twice.
|
|
17
|
+
*/
|
|
18
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
21
|
+
/** Seconds for one full loop. Longer strips need more. */
|
|
22
|
+
duration?: number;
|
|
23
|
+
/** Travel direction; left and right are physical, the same on a right-to-left page. Up and down need a height on the strip. */
|
|
24
|
+
direction?: 'left' | 'right' | 'up' | 'down';
|
|
25
|
+
/** Copies of the content in the strip, at least 2. More copies make a short list denser; the strip is covered either way. */
|
|
26
|
+
repeat?: number;
|
|
27
|
+
/** Space between items and between the copies. Any CSS length. */
|
|
28
|
+
gap?: string;
|
|
29
|
+
/** Pause while hovered (pointer devices only). */
|
|
30
|
+
pauseOnHover?: boolean;
|
|
31
|
+
/** Fade the strip's edges. */
|
|
32
|
+
fade?: boolean;
|
|
33
|
+
/** Accessible name; makes the strip a named region ("Customers"). */
|
|
34
|
+
label?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { duration = 28, direction = 'left', repeat = 2, gap = '2rem', pauseOnHover = true, fade = true, label, class: className, style, ...rest } = Astro.props;
|
|
38
|
+
const hidden = rest['aria-hidden'] === true || rest['aria-hidden'] === 'true';
|
|
39
|
+
const vertical = direction === 'up' || direction === 'down';
|
|
40
|
+
const reverse = direction === 'right' || direction === 'down';
|
|
41
|
+
const copies = Math.max(2, Math.round(repeat) || 2);
|
|
42
|
+
const vars = [`--ma-marquee-duration:${duration}s`, `--ma-marquee-gap:${gap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<div
|
|
46
|
+
class:list={['ma-marquee', className]}
|
|
47
|
+
data-vertical={vertical ? '' : undefined}
|
|
48
|
+
data-reverse={reverse ? '' : undefined}
|
|
49
|
+
data-pause={pauseOnHover ? '' : undefined}
|
|
50
|
+
data-fade={fade ? '' : undefined}
|
|
51
|
+
tabindex={hidden ? undefined : 0}
|
|
52
|
+
role={label ? 'region' : undefined}
|
|
53
|
+
aria-label={label}
|
|
54
|
+
style={vars}
|
|
55
|
+
{...rest}
|
|
56
|
+
>
|
|
57
|
+
<div class="ma-marquee__track" style={copies > 2 ? `--ma-marquee-copies:${copies}` : undefined}>
|
|
58
|
+
<div class="ma-marquee__group"><slot /></div>
|
|
59
|
+
{Array.from({ length: copies - 1 }, () => <div class="ma-marquee__group" aria-hidden="true" inert><slot /></div>)}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<style is:global>
|
|
64
|
+
@layer components {
|
|
65
|
+
:where(.ma-marquee) {
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
/* A flex/grid child defaults to min-width: auto = its min-content, and the
|
|
68
|
+
track is max-content wide, so without this the strip leaks its whole
|
|
69
|
+
width into the parent column and locks the grid. */
|
|
70
|
+
min-width: 0;
|
|
71
|
+
max-width: 100%;
|
|
72
|
+
contain: layout paint;
|
|
73
|
+
}
|
|
74
|
+
:where(.ma-marquee[data-fade]) {
|
|
75
|
+
mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
|
|
76
|
+
}
|
|
77
|
+
:where(.ma-marquee[data-fade][data-vertical]) {
|
|
78
|
+
mask-image: linear-gradient(180deg, transparent, #000 10%, #000 90%, transparent);
|
|
79
|
+
}
|
|
80
|
+
/* at least n / (n - 1) strips wide for n copies (two copies: twice the strip), and the copies grow to fill it, so
|
|
81
|
+
each is at least 1 / (n - 1) of the strip and the n - 1 copies in view always cover it: the loop never runs out
|
|
82
|
+
of content before it wraps */
|
|
83
|
+
:where(.ma-marquee__track) {
|
|
84
|
+
display: flex;
|
|
85
|
+
width: max-content;
|
|
86
|
+
min-inline-size: calc(100% * var(--ma-marquee-copies, 2) / (var(--ma-marquee-copies, 2) - 1));
|
|
87
|
+
animation: ma-marquee var(--ma-marquee-duration, 28s) linear infinite;
|
|
88
|
+
}
|
|
89
|
+
:where(.ma-marquee[data-vertical] .ma-marquee__track) {
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
width: auto;
|
|
92
|
+
height: max-content;
|
|
93
|
+
min-inline-size: auto;
|
|
94
|
+
/* a percentage of a definite height only: a strip with no height set has nothing to fill */
|
|
95
|
+
min-block-size: calc(100% * var(--ma-marquee-copies, 2) / (var(--ma-marquee-copies, 2) - 1));
|
|
96
|
+
animation-name: ma-marquee-vertical;
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-marquee[data-reverse] .ma-marquee__track) {
|
|
99
|
+
animation-direction: reverse;
|
|
100
|
+
}
|
|
101
|
+
/* right to left the track sits at the strip's right edge and the copies lie to the left of the first, so the loop
|
|
102
|
+
starts one copy to the right and comes back to rest (--ma-marquee-rtl in the keyframes); left and right stay
|
|
103
|
+
physical. [dir] and not :dir(): :dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a
|
|
104
|
+
page that sets only dir never matches. An ltr island one level inside an rtl page is set back */
|
|
105
|
+
:where([dir='rtl'] .ma-marquee, .ma-marquee[dir='rtl']) {
|
|
106
|
+
--ma-marquee-rtl: 1;
|
|
107
|
+
}
|
|
108
|
+
:where([dir='rtl'] [dir='ltr'] .ma-marquee, [dir='rtl'] .ma-marquee[dir='ltr']) {
|
|
109
|
+
--ma-marquee-rtl: 0;
|
|
110
|
+
}
|
|
111
|
+
/* space-around: a stretched copy spreads its items, and the half spaces at its two ends add up to one full space
|
|
112
|
+
across the seam, so the rhythm is even all the way round */
|
|
113
|
+
:where(.ma-marquee__group) {
|
|
114
|
+
display: flex;
|
|
115
|
+
flex: 1 0 auto;
|
|
116
|
+
align-items: center;
|
|
117
|
+
justify-content: space-around;
|
|
118
|
+
gap: var(--ma-marquee-gap, 2rem);
|
|
119
|
+
padding-inline-end: var(--ma-marquee-gap, 2rem);
|
|
120
|
+
}
|
|
121
|
+
:where(.ma-marquee[data-vertical] .ma-marquee__group) {
|
|
122
|
+
flex-direction: column;
|
|
123
|
+
padding-inline-end: 0;
|
|
124
|
+
padding-block-end: var(--ma-marquee-gap, 2rem);
|
|
125
|
+
}
|
|
126
|
+
@media (hover: hover) and (pointer: fine) {
|
|
127
|
+
:where(.ma-marquee[data-pause]:hover .ma-marquee__track) {
|
|
128
|
+
animation-play-state: paused;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/* focus pauses whatever pauseOnHover says: it is the keyboard's only way to stop the strip */
|
|
132
|
+
:where(.ma-marquee:focus-within .ma-marquee__track) {
|
|
133
|
+
animation-play-state: paused;
|
|
134
|
+
}
|
|
135
|
+
@media (prefers-reduced-motion: reduce) {
|
|
136
|
+
:where(.ma-marquee__track) {
|
|
137
|
+
animation: none;
|
|
138
|
+
min-inline-size: auto;
|
|
139
|
+
min-block-size: auto;
|
|
140
|
+
}
|
|
141
|
+
:where(.ma-marquee) {
|
|
142
|
+
overflow: auto;
|
|
143
|
+
mask-image: none;
|
|
144
|
+
}
|
|
145
|
+
:where(.ma-marquee__group[aria-hidden]) {
|
|
146
|
+
display: none;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* one copy to the left: from rest, or right to left from one copy to the right back to rest */
|
|
152
|
+
@keyframes ma-marquee {
|
|
153
|
+
from {
|
|
154
|
+
translate: calc(var(--ma-marquee-rtl, 0) * 100% / var(--ma-marquee-copies, 2)) 0;
|
|
155
|
+
}
|
|
156
|
+
to {
|
|
157
|
+
translate: calc((var(--ma-marquee-rtl, 0) - 1) * 100% / var(--ma-marquee-copies, 2)) 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
@keyframes ma-marquee-vertical {
|
|
161
|
+
to {
|
|
162
|
+
translate: 0 calc(-100% / var(--ma-marquee-copies, 2));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Marquee',
|
|
5
|
+
slug: 'marquee',
|
|
6
|
+
title: 'Marquee',
|
|
7
|
+
description:
|
|
8
|
+
'Infinite scrolling strip for logos, quotes or tags in any direction. The content is rendered twice (or repeat times) and the track moves by exactly one copy, so there is never a seam; a copy narrower than the strip stretches to fill it, so a short list never leaves a blank band. Pauses on hover; becomes a static scrollable strip under reduced motion. Pure CSS.',
|
|
9
|
+
summary: 'An infinite marquee for logos, quotes or tags in any direction. It pauses on hover and never shows a gap.',
|
|
10
|
+
category: 'loop',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['always', 'hover'],
|
|
13
|
+
support: { baseline: 'widely', needs: ['mask-image'] },
|
|
14
|
+
readout: '<Marquee duration={16}>',
|
|
15
|
+
usage: `<Marquee duration={28} gap="3rem">\n <img src="/logos/a.svg" alt="A" />\n <img src="/logos/b.svg" alt="B" />\n</Marquee>`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'duration', type: 'number', default: '28', description: 'Seconds for one full loop. Scale with content width: roughly 1 s per 60 px keeps logos readable.' },
|
|
18
|
+
{ name: 'direction', type: "'left' | 'right' | 'up' | 'down'", default: "'left'", description: 'Travel direction. Left and right are physical: the same on a right-to-left page. Up and down need a height on the strip (a class or a style), which the copies then fill.' },
|
|
19
|
+
{ name: 'repeat', type: 'number', default: '2', description: 'Copies of the content, at least 2. A copy narrower than the strip is stretched and its items spaced out; more copies keep a short list dense instead.' },
|
|
20
|
+
{ name: 'gap', type: 'string', default: "'2rem'", description: 'Space between items and between the copies.' },
|
|
21
|
+
{ name: 'pauseOnHover', type: 'boolean', default: 'true', description: 'Pause while hovered, on pointer devices only.' },
|
|
22
|
+
{ name: 'fade', type: 'boolean', default: 'true', description: 'Fade the strip edges with a mask.' },
|
|
23
|
+
{ name: 'label', type: 'string', description: 'Accessible name; the strip becomes a named region ("Customers").' },
|
|
24
|
+
],
|
|
25
|
+
reducedMotion: 'The strip stops, the extra copies are hidden, and the container becomes scrollable so nothing is unreachable.',
|
|
26
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
27
|
+
craft: [
|
|
28
|
+
'Linear timing: constant motion must not ease, or the loop point shows as a hitch.',
|
|
29
|
+
'The track is at least the strip plus one copy\'s share wide (twice the strip with two copies), a percentage of the strip, and the copies grow to fill it with space-around. So the loop never runs out of content, whatever the content\'s width, and nothing is measured; the half spaces at a copy\'s two ends add up to one full space across the seam.',
|
|
30
|
+
'The copies after the first are aria-hidden and inert, so screen readers hear each item once and Tab never reaches a link twice.',
|
|
31
|
+
'The strip is a tab stop unless it is aria-hidden: focus pauses it (WCAG 2.2.2 wants a way to stop moving content, and a keyboard cannot hover), and under reduced motion (where it is a scrollable strip) the arrow keys scroll it. label names it as a region.',
|
|
32
|
+
'Hover-pause is gated on (hover: hover) so touch devices never freeze it on tap.',
|
|
33
|
+
'contain: layout paint keeps the moving track from invalidating the page around it.',
|
|
34
|
+
],
|
|
35
|
+
related: ['reveal'],
|
|
36
|
+
intents: ['logo marquee', 'infinite scroll strip', 'logo loop', 'ticker', 'scrolling logos astro'],
|
|
37
|
+
replaces: ['LogoLoop (React Bits)', 'Marquee (Magic UI)', 'InfiniteMovingCards (Aceternity)', 'react-fast-marquee'],
|
|
38
|
+
} satisfies ComponentMeta;
|