@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,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* BrowserFrame — a browser window around a screenshot or any content: the
|
|
4
|
+
* three lights, an address bar, optional tabs with one active. Static and
|
|
5
|
+
* zero JS; the chrome scales with the frame's own width through container
|
|
6
|
+
* query units, and every colour is a theme token, so `theme="dark"` or
|
|
7
|
+
* "light" pins one look and "auto" follows the page. Pass `src` (an
|
|
8
|
+
* imported image or a URL) for a screenshot, or put anything in the slot.
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
11
|
+
import type { ImageMetadata } from 'astro';
|
|
12
|
+
import { Image } from 'astro:assets';
|
|
13
|
+
|
|
14
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
15
|
+
/** Shown in the address bar. */
|
|
16
|
+
url?: string;
|
|
17
|
+
/** Tab labels; `active` is the index that is selected. */
|
|
18
|
+
tabs?: string[];
|
|
19
|
+
active?: number;
|
|
20
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
21
|
+
/** A screenshot: an imported image (optimised by Astro) or a plain URL. */
|
|
22
|
+
src?: ImageMetadata | string;
|
|
23
|
+
alt?: string;
|
|
24
|
+
/** Corner radius, any CSS length. */
|
|
25
|
+
radius?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { url = 'https://example.com', tabs, active = 0, theme = 'auto', src, alt = '', radius = '0.75rem', class: className, style, ...rest } = Astro.props;
|
|
29
|
+
const host = url.replace(/^https?:\/\//, '');
|
|
30
|
+
const secure = /^https:\/\//.test(url) || !/^https?:\/\//.test(url);
|
|
31
|
+
const vars = [`--ma-browser-r:${radius}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<div class:list={['ma-browser', className]} data-theme={theme === 'auto' ? undefined : theme} style={vars} {...rest}>
|
|
35
|
+
<div class="ma-browser__bar" aria-hidden="true">
|
|
36
|
+
<span class="ma-browser__lights"><i></i><i></i><i></i></span>
|
|
37
|
+
{tabs && tabs.length > 0 && (
|
|
38
|
+
<span class="ma-browser__tabs">
|
|
39
|
+
{tabs.map((t, i) => <span class="ma-browser__tab" data-active={i === active ? '' : undefined}>{t}</span>)}
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
<span class="ma-browser__url">
|
|
43
|
+
<svg viewBox="0 0 16 16" class="ma-browser__lock">{secure ? <path d="M4.5 7V5.5a3.5 3.5 0 0 1 7 0V7M3.5 7h9v6h-9z" /> : <path d="M8 3v6M8 11.5v1" />}</svg>
|
|
44
|
+
<span>{host}</span>
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="ma-browser__body">
|
|
48
|
+
{src && typeof src === 'string' && <img src={src} alt={alt} loading="lazy" decoding="async" />}
|
|
49
|
+
{src && typeof src !== 'string' && <Image src={src} alt={alt} />}
|
|
50
|
+
<slot />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<style is:global>
|
|
55
|
+
@layer components {
|
|
56
|
+
:where(.ma-browser) {
|
|
57
|
+
container-type: inline-size;
|
|
58
|
+
display: grid;
|
|
59
|
+
/* one column the frame's own width: an auto column grew to the bar's unbroken line (tabs and address are nowrap)
|
|
60
|
+
and ran the frame out of a phone; now the tabs and the address clip inside it */
|
|
61
|
+
grid-template-columns: minmax(0, 1fr);
|
|
62
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
63
|
+
overflow: clip;
|
|
64
|
+
border: 1px solid var(--ma-edge);
|
|
65
|
+
border-radius: var(--ma-browser-r, 0.75rem);
|
|
66
|
+
background: var(--ma-panel);
|
|
67
|
+
color: var(--ma-ink);
|
|
68
|
+
box-shadow: 0 1px 2px var(--ma-glow);
|
|
69
|
+
}
|
|
70
|
+
/* pinned themes: base.css's whole token set of that theme, locally (theme.css's tokens follow data-ma-theme) */
|
|
71
|
+
:where(.ma-browser[data-theme='dark']) {
|
|
72
|
+
color-scheme: dark;
|
|
73
|
+
--ma-shine: rgb(255 255 255 / 0.32);
|
|
74
|
+
--ma-scrim: rgb(10 10 12 / 0.72);
|
|
75
|
+
--ma-edge: rgb(255 255 255 / 0.1);
|
|
76
|
+
--ma-ink: rgb(255 255 255 / 0.92);
|
|
77
|
+
--ma-panel: rgb(24 24 27 / 0.94);
|
|
78
|
+
--ma-glow: rgb(255 255 255 / 0.14);
|
|
79
|
+
--ma-dark: 1;
|
|
80
|
+
}
|
|
81
|
+
:where(.ma-browser[data-theme='light']) {
|
|
82
|
+
color-scheme: light;
|
|
83
|
+
--ma-shine: rgb(255 255 255 / 0.85);
|
|
84
|
+
--ma-scrim: rgb(255 255 255 / 0.72);
|
|
85
|
+
--ma-edge: rgb(0 0 0 / 0.08);
|
|
86
|
+
--ma-ink: rgb(0 0 0 / 0.9);
|
|
87
|
+
--ma-panel: rgb(255 255 255 / 0.92);
|
|
88
|
+
--ma-glow: rgb(0 0 0 / 0.1);
|
|
89
|
+
--ma-dark: 0;
|
|
90
|
+
}
|
|
91
|
+
:where(.ma-browser__bar) {
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: clamp(0.4rem, 1.5cqw, 0.75rem);
|
|
95
|
+
padding: clamp(0.35rem, 1.4cqw, 0.6rem) clamp(0.5rem, 2cqw, 0.9rem);
|
|
96
|
+
border-bottom: 1px solid var(--ma-edge);
|
|
97
|
+
font-size: clamp(0.55rem, 1.9cqw, 0.8rem);
|
|
98
|
+
line-height: 1;
|
|
99
|
+
white-space: nowrap;
|
|
100
|
+
}
|
|
101
|
+
:where(.ma-browser__lights) {
|
|
102
|
+
display: inline-flex;
|
|
103
|
+
gap: 0.5em;
|
|
104
|
+
flex: none;
|
|
105
|
+
}
|
|
106
|
+
:where(.ma-browser__lights > i) {
|
|
107
|
+
width: 1em;
|
|
108
|
+
height: 1em;
|
|
109
|
+
border-radius: 50%;
|
|
110
|
+
background: var(--ma-edge);
|
|
111
|
+
}
|
|
112
|
+
:where(.ma-browser__lights > i:nth-child(1)) { background: #ff5f57; }
|
|
113
|
+
:where(.ma-browser__lights > i:nth-child(2)) { background: #febc2e; }
|
|
114
|
+
:where(.ma-browser__lights > i:nth-child(3)) { background: #28c840; }
|
|
115
|
+
:where(.ma-browser__tabs) {
|
|
116
|
+
display: inline-flex;
|
|
117
|
+
gap: 0.25em;
|
|
118
|
+
min-width: 0;
|
|
119
|
+
overflow: clip;
|
|
120
|
+
}
|
|
121
|
+
:where(.ma-browser__tab) {
|
|
122
|
+
padding: 0.5em 0.9em;
|
|
123
|
+
border-radius: 0.5em;
|
|
124
|
+
opacity: var(--ma-dim, 0.7);
|
|
125
|
+
overflow: clip;
|
|
126
|
+
text-overflow: ellipsis;
|
|
127
|
+
}
|
|
128
|
+
:where(.ma-browser__tab[data-active]) {
|
|
129
|
+
opacity: 1;
|
|
130
|
+
background: color-mix(in srgb, var(--ma-ink) 8%, transparent);
|
|
131
|
+
}
|
|
132
|
+
:where(.ma-browser__url) {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 0.5em;
|
|
136
|
+
flex: 1 1 auto;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
padding: 0.5em 0.9em;
|
|
139
|
+
border-radius: 0.5em;
|
|
140
|
+
background: color-mix(in srgb, var(--ma-ink) 5%, transparent);
|
|
141
|
+
opacity: 0.8;
|
|
142
|
+
overflow: clip;
|
|
143
|
+
text-overflow: ellipsis;
|
|
144
|
+
}
|
|
145
|
+
:where(.ma-browser__lock) {
|
|
146
|
+
width: 1em;
|
|
147
|
+
height: 1em;
|
|
148
|
+
flex: none;
|
|
149
|
+
fill: none;
|
|
150
|
+
stroke: currentColor;
|
|
151
|
+
stroke-width: 1.4;
|
|
152
|
+
stroke-linecap: round;
|
|
153
|
+
stroke-linejoin: round;
|
|
154
|
+
}
|
|
155
|
+
:where(.ma-browser__body) {
|
|
156
|
+
position: relative;
|
|
157
|
+
min-height: 0;
|
|
158
|
+
overflow: clip;
|
|
159
|
+
}
|
|
160
|
+
:where(.ma-browser__body > img) {
|
|
161
|
+
display: block;
|
|
162
|
+
width: 100%;
|
|
163
|
+
height: auto;
|
|
164
|
+
}
|
|
165
|
+
/* static: nothing moves, so nothing to reduce */
|
|
166
|
+
@media (prefers-reduced-motion: reduce) {
|
|
167
|
+
:where(.ma-browser) {
|
|
168
|
+
animation: none;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'BrowserFrame',
|
|
5
|
+
slug: 'browser-frame',
|
|
6
|
+
title: 'Browser Frame',
|
|
7
|
+
description: 'A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active. Static, zero JavaScript. The chrome scales with the frame\'s own width through container query units, and every colour is a theme token, so theme="dark" or "light" pins one look and "auto" follows the page.',
|
|
8
|
+
summary: 'A browser window mockup for screenshots: traffic lights, an address bar, optional tabs, light or dark.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['always'],
|
|
12
|
+
support: { baseline: 'widely', needs: ['container-units'] },
|
|
13
|
+
readout: '<BrowserFrame url="https://moonarc.dev">',
|
|
14
|
+
usage: `---\nimport shot from '../assets/home.png';\n---\n<BrowserFrame url="https://moonarc.dev" tabs={['Home', 'Components']} src={shot} alt="The home page" />\n<BrowserFrame url="https://example.com" theme="dark">\n <MyHero />\n</BrowserFrame>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'url', type: 'string', default: "'https://example.com'", description: 'Shown in the address bar; https shows a lock.' },
|
|
17
|
+
{ name: 'tabs', type: 'string[]', description: 'Tab labels.' },
|
|
18
|
+
{ name: 'active', type: 'number', default: '0', description: 'Index of the selected tab.' },
|
|
19
|
+
{ name: 'theme', type: "'light' | 'dark' | 'auto'", default: "'auto'", description: 'Pin the base tokens to one theme, or follow the page. The theme.css tokens follow the page; add data-ma-theme="dark" as well to re-derive them inside the frame.' },
|
|
20
|
+
{ name: 'src', type: 'ImageMetadata | string', description: 'A screenshot: an imported image goes through Astro\'s <Image>; a URL is a plain <img>.' },
|
|
21
|
+
{ name: 'alt', type: 'string', default: "''", description: 'Alt text for the screenshot.' },
|
|
22
|
+
{ name: 'radius', type: 'string', default: "'0.75rem'", description: 'Corner radius.' },
|
|
23
|
+
],
|
|
24
|
+
reducedMotion: 'Nothing moves; nothing changes.',
|
|
25
|
+
clientRouter: 'Static markup; nothing to rebind.',
|
|
26
|
+
craft: [
|
|
27
|
+
'Sized in cqw with clamps: at 320 px wide the bar is a thin strip, at 1200 px it is a real toolbar, and the frame never dictates its own width.',
|
|
28
|
+
'A pinned theme redefines every base token locally (--ma-panel, --ma-edge, --ma-ink, --ma-glow, --ma-shine, --ma-scrim, --ma-dark) with base.css\'s values for that theme rather than painting its own colours, so a dark frame on a light page, and the library components inside it, match the library\'s dark exactly. The theme.css tokens (--background, --card, --primary …) still follow the page: add data-ma-theme="dark" to the frame as well to re-derive them inside it, and set --ma-brand there too if the page sets one.',
|
|
29
|
+
'The lights keep their three colours in both themes; they are the one thing a browser frame is recognised by.',
|
|
30
|
+
'Content is a slot, so the frame can hold a live section, a video, or a ScrollScrub with rotateX for the container-scroll look.',
|
|
31
|
+
],
|
|
32
|
+
related: ['scroll-scrub', 'scroll-expand', 'code-tabs'],
|
|
33
|
+
intents: ['browser window mockup', 'browser frame component', 'safari frame screenshot', 'mac window mockup', 'device frame astro'],
|
|
34
|
+
replaces: ['Safari (Magic UI)', 'BrowserComponent (Cult UI)', 'BrowserWindow (Eldora UI)'],
|
|
35
|
+
fixture: { props: { url: 'https://moonarc.dev' }, slot: 'Fixture content for BrowserFrame', expect: 'Fixture content for BrowserFrame' },
|
|
36
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* BubbleText — the letter under the cursor swells to the heaviest weight
|
|
4
|
+
* and lifts a little; its neighbours follow at half and a quarter. Letters
|
|
5
|
+
* are split on the server; the neighbours are :hover + * and :has(+ :hover),
|
|
6
|
+
* so it is pure CSS. Needs a variable font for a continuous swell; static
|
|
7
|
+
* families step between the weights they have.
|
|
8
|
+
*/
|
|
9
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
10
|
+
|
|
11
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
12
|
+
/** The text. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** Element to render. */
|
|
15
|
+
as?: HTMLTag;
|
|
16
|
+
/** Resting weight. */
|
|
17
|
+
min?: number;
|
|
18
|
+
/** Weight under the cursor. */
|
|
19
|
+
max?: number;
|
|
20
|
+
/** Duration in ms. Unset: the preset's --ma-duration. */
|
|
21
|
+
duration?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { text, as: Tag = 'span', min = 400, max = 800, duration, class: className, style, ...rest } = Astro.props;
|
|
25
|
+
const vars = [`--ma-bubble-min:${min}`, `--ma-bubble-max:${max}`, duration !== undefined && `--ma-bubble-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
26
|
+
// graphemes, not code points: an emoji with a skin tone or a letter with a combining accent swells as one letter
|
|
27
|
+
const graphemes = (s: string) => Array.from(new Intl.Segmenter().segment(s), (g) => g.segment);
|
|
28
|
+
// dir="auto": words and letters are inline-blocks, which bidi orders by the page's direction, so Latin text on a
|
|
29
|
+
// right-to-left page read back to front. The element takes its direction from its own text instead.
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<Tag class:list={['ma-bubble', className]} dir="auto" style={vars} {...rest}>
|
|
33
|
+
{text.split(/(\s+)/).map((word) =>
|
|
34
|
+
/^\s+$/.test(word) ? (
|
|
35
|
+
' '
|
|
36
|
+
) : (
|
|
37
|
+
<span class="ma-bubble__word" aria-hidden="true">
|
|
38
|
+
{graphemes(word).map((c) => <span class="ma-bubble__char">{c}</span>)}
|
|
39
|
+
</span>
|
|
40
|
+
),
|
|
41
|
+
)}
|
|
42
|
+
<span class="ma-sr">{text}</span>
|
|
43
|
+
</Tag>
|
|
44
|
+
|
|
45
|
+
<style is:global>
|
|
46
|
+
@layer components {
|
|
47
|
+
:where(.ma-bubble) {
|
|
48
|
+
white-space: pre-wrap;
|
|
49
|
+
font-weight: var(--ma-bubble-min, 400);
|
|
50
|
+
}
|
|
51
|
+
/* a word is one inline-block, so lines break between words, never between letters */
|
|
52
|
+
:where(.ma-bubble__word) {
|
|
53
|
+
display: inline-block;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
}
|
|
56
|
+
:where(.ma-bubble__char) {
|
|
57
|
+
display: inline-block;
|
|
58
|
+
font-weight: var(--ma-bubble-min, 400);
|
|
59
|
+
transition:
|
|
60
|
+
font-weight var(--ma-bubble-dur, var(--ma-duration)) var(--ma-ease),
|
|
61
|
+
translate var(--ma-bubble-dur, var(--ma-duration)) var(--ma-ease);
|
|
62
|
+
}
|
|
63
|
+
@media (hover: hover) {
|
|
64
|
+
:where(.ma-bubble__char:hover) {
|
|
65
|
+
font-weight: var(--ma-bubble-max, 800);
|
|
66
|
+
translate: 0 calc(-1 * var(--ma-travel-hover));
|
|
67
|
+
transition-duration: var(--ma-duration-fast);
|
|
68
|
+
}
|
|
69
|
+
/* neighbours: half the swell, then a quarter */
|
|
70
|
+
:where(.ma-bubble__char:hover + .ma-bubble__char),
|
|
71
|
+
:where(.ma-bubble__char:has(+ .ma-bubble__char:hover)) {
|
|
72
|
+
font-weight: calc((var(--ma-bubble-min, 400) + var(--ma-bubble-max, 800)) / 2);
|
|
73
|
+
translate: 0 calc(-0.5 * var(--ma-travel-hover));
|
|
74
|
+
transition-duration: var(--ma-duration-fast);
|
|
75
|
+
}
|
|
76
|
+
:where(.ma-bubble__char:hover + .ma-bubble__char + .ma-bubble__char),
|
|
77
|
+
:where(.ma-bubble__char:has(+ .ma-bubble__char + .ma-bubble__char:hover)) {
|
|
78
|
+
font-weight: calc(var(--ma-bubble-min, 400) + (var(--ma-bubble-max, 800) - var(--ma-bubble-min, 400)) / 4);
|
|
79
|
+
translate: 0 calc(-0.25 * var(--ma-travel-hover));
|
|
80
|
+
transition-duration: var(--ma-duration-fast);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
@media (prefers-reduced-motion: reduce) {
|
|
84
|
+
:where(.ma-bubble__char) {
|
|
85
|
+
transition: none;
|
|
86
|
+
translate: 0 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'BubbleText',
|
|
5
|
+
slug: 'bubble-text',
|
|
6
|
+
title: 'Bubble Text',
|
|
7
|
+
description:
|
|
8
|
+
'Swells the letter under the cursor to the heaviest weight and lifts it; its neighbours follow at half and a quarter, and everything settles when the pointer leaves. Letters split on the server, neighbours found with :has(). Pure CSS on a variable font.',
|
|
9
|
+
summary: 'A variable-font text effect: the letter under the cursor swells and lifts, and its neighbours follow.',
|
|
10
|
+
category: 'text',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['hover'],
|
|
13
|
+
support: { baseline: 'widely', needs: ['font-variation', 'has'] },
|
|
14
|
+
readout: '<BubbleText min={400} max={800}>',
|
|
15
|
+
usage: `<h1><BubbleText text="Hover the letters." /></h1>\n<BubbleText text="light to black" min={300} max={900} duration={300} />`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'text', type: 'string', description: 'The text; split into words and characters on the server, so lines break between words only.' },
|
|
18
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
19
|
+
{ name: 'min', type: 'number', default: '400', description: 'Resting weight.' },
|
|
20
|
+
{ name: 'max', type: 'number', default: '800', description: 'Weight under the cursor. The neighbours get the midpoint and the quarter.' },
|
|
21
|
+
{ name: 'duration', type: 'number', default: 'preset (ui: 450)', description: 'Release duration in ms; the swell itself is the fast duration.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'The weight still follows the cursor, because that is direct manipulation, but it changes instantly and nothing lifts.',
|
|
24
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
25
|
+
craft: [
|
|
26
|
+
'Swell in 150 ms, release on the preset duration: the asymmetry is what makes it feel like pressing into something soft.',
|
|
27
|
+
'Neighbours at half and a quarter through :hover + * and :has(+ :hover): a bump, not a spotlight. Three letters wide is the width of a fingertip; the bump stops at a word boundary, which is also where the eye stops.',
|
|
28
|
+
'font-weight on a variable axis is one property with no layout trick; the line does reflow as widths change, so keep it to headlines.',
|
|
29
|
+
'The lift is the preset\'s hover travel token (2 px on snap, 6 px on lively), so the swell keeps the page\'s scale.',
|
|
30
|
+
'A visually hidden span (.ma-sr), aria-hidden letters: one string to assistive tech.',
|
|
31
|
+
'A character is a grapheme (Intl.Segmenter), so an emoji with a skin tone or a letter with a combining accent counts as one. The inline-block characters would follow the page\'s direction, so the element takes dir="auto" and Latin text on a right-to-left page still runs left to right.',
|
|
32
|
+
],
|
|
33
|
+
related: ['weight-wave', 'text-roll', 'magnetic'],
|
|
34
|
+
intents: ['bubble text hover', 'text weight hover effect', 'letters swell under cursor', 'variable font hover', 'hover text bulge'],
|
|
35
|
+
replaces: ['BubbleText (Hover.dev)', 'VariableProximity (React Bits) under the cursor'],
|
|
36
|
+
// an emoji with a skin tone is one letter (a grapheme), not two code points
|
|
37
|
+
fixture: { props: { text: 'Fixture content for BubbleText 👍🏽' }, expect: 'ma-bubble__char">👍🏽</span>' },
|
|
38
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CardStack — cards stick as you scroll and each one shrinks back as the
|
|
4
|
+
* next slides over it. Zero JS: every child is sticky, and each card's
|
|
5
|
+
* scale is bound to the *next* card's view timeline through timeline-scope,
|
|
6
|
+
* so the shrink is exactly in step with the cover. Up to eight cards.
|
|
7
|
+
* Without scroll timelines, or without timeline-scope (Chrome 115), the
|
|
8
|
+
* cards still stack; they just do not shrink.
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
11
|
+
|
|
12
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
13
|
+
/** Sticky offset from the top of the viewport, any CSS length. */
|
|
14
|
+
top?: string;
|
|
15
|
+
/** Extra offset per card so the stack's edges peek out. */
|
|
16
|
+
peek?: string;
|
|
17
|
+
/** Scale a covered card shrinks to. */
|
|
18
|
+
scale?: number;
|
|
19
|
+
/** Minimum height of each card's slot, so there is something to scroll through. */
|
|
20
|
+
height?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { top = '10vh', peek = '1rem', scale = 0.92, height = '70vh', class: className, style, ...rest } = Astro.props;
|
|
24
|
+
const vars = [`--ma-stack-top:${top}`, `--ma-stack-peek:${peek}`, `--ma-stack-scale:${scale}`, `--ma-stack-h:${height}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<div class:list={['ma-stack', className]} style={vars} {...rest}>
|
|
28
|
+
<slot />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<style is:global>
|
|
32
|
+
@layer components {
|
|
33
|
+
:where(.ma-stack) {
|
|
34
|
+
display: grid;
|
|
35
|
+
gap: var(--ma-stack-peek, 1rem);
|
|
36
|
+
timeline-scope: --ma-c1, --ma-c2, --ma-c3, --ma-c4, --ma-c5, --ma-c6, --ma-c7, --ma-c8;
|
|
37
|
+
}
|
|
38
|
+
:where(.ma-stack > *) {
|
|
39
|
+
position: sticky;
|
|
40
|
+
top: calc(var(--ma-stack-top, 10vh) + var(--ma-stack-i, 0) * var(--ma-stack-peek, 1rem));
|
|
41
|
+
min-height: var(--ma-stack-h, 70vh);
|
|
42
|
+
transform-origin: 50% 0;
|
|
43
|
+
}
|
|
44
|
+
:where(.ma-stack > :nth-child(1)) { --ma-stack-i: 0; view-timeline-name: --ma-c1; }
|
|
45
|
+
:where(.ma-stack > :nth-child(2)) { --ma-stack-i: 1; view-timeline-name: --ma-c2; }
|
|
46
|
+
:where(.ma-stack > :nth-child(3)) { --ma-stack-i: 2; view-timeline-name: --ma-c3; }
|
|
47
|
+
:where(.ma-stack > :nth-child(4)) { --ma-stack-i: 3; view-timeline-name: --ma-c4; }
|
|
48
|
+
:where(.ma-stack > :nth-child(5)) { --ma-stack-i: 4; view-timeline-name: --ma-c5; }
|
|
49
|
+
:where(.ma-stack > :nth-child(6)) { --ma-stack-i: 5; view-timeline-name: --ma-c6; }
|
|
50
|
+
:where(.ma-stack > :nth-child(7)) { --ma-stack-i: 6; view-timeline-name: --ma-c7; }
|
|
51
|
+
:where(.ma-stack > :nth-child(8)) { --ma-stack-i: 7; view-timeline-name: --ma-c8; }
|
|
52
|
+
/* timeline-scope arrived a release after view timelines (Chrome 116): without it the names never reach a sibling */
|
|
53
|
+
@supports (animation-timeline: view()) and (timeline-scope: none) {
|
|
54
|
+
:where(.ma-stack > :not(:last-child)) {
|
|
55
|
+
animation: ma-stack linear both;
|
|
56
|
+
animation-range: entry 0% entry 100%;
|
|
57
|
+
}
|
|
58
|
+
:where(.ma-stack > :nth-child(1):not(:last-child)) { animation-timeline: --ma-c2; }
|
|
59
|
+
:where(.ma-stack > :nth-child(2):not(:last-child)) { animation-timeline: --ma-c3; }
|
|
60
|
+
:where(.ma-stack > :nth-child(3):not(:last-child)) { animation-timeline: --ma-c4; }
|
|
61
|
+
:where(.ma-stack > :nth-child(4):not(:last-child)) { animation-timeline: --ma-c5; }
|
|
62
|
+
:where(.ma-stack > :nth-child(5):not(:last-child)) { animation-timeline: --ma-c6; }
|
|
63
|
+
:where(.ma-stack > :nth-child(6):not(:last-child)) { animation-timeline: --ma-c7; }
|
|
64
|
+
:where(.ma-stack > :nth-child(7):not(:last-child)) { animation-timeline: --ma-c8; }
|
|
65
|
+
}
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
:where(.ma-stack > *) {
|
|
68
|
+
animation: none;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@keyframes ma-stack {
|
|
74
|
+
to {
|
|
75
|
+
scale: var(--ma-stack-scale, 0.92);
|
|
76
|
+
filter: brightness(0.85);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'CardStack',
|
|
5
|
+
slug: 'card-stack',
|
|
6
|
+
title: 'Card Stack',
|
|
7
|
+
description: 'Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages. Zero JavaScript: sticky positioning plus each card\'s scale bound to the next card\'s view timeline. Up to eight cards.',
|
|
8
|
+
summary: 'Cards that stick as you scroll, each shrinking and dimming as the next slides over it, as on Apple product pages.',
|
|
9
|
+
category: 'scroll',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['scroll'],
|
|
12
|
+
support: { baseline: 'limited', needs: ['scroll-timeline', 'timeline-scope'], fallback: 'cards still stack with sticky positioning, without the shrink (Firefox, Chrome 115)' },
|
|
13
|
+
readout: '<CardStack scale={0.92}>',
|
|
14
|
+
usage: `<CardStack top="8vh" peek="1.25rem">\n <section class="rounded-2xl bg-surface p-8">One</section>\n <section class="rounded-2xl bg-surface p-8">Two</section>\n <section class="rounded-2xl bg-surface p-8">Three</section>\n</CardStack>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'top', type: 'string', default: "'10vh'", description: 'Sticky offset from the top of the viewport.' },
|
|
17
|
+
{ name: 'peek', type: 'string', default: "'1rem'", description: 'Extra offset per card so the stacked edges show.' },
|
|
18
|
+
{ name: 'scale', type: 'number', default: '0.92', description: 'Scale a covered card shrinks to.' },
|
|
19
|
+
{ name: 'height', type: 'string', default: "'70vh'", description: 'Minimum height of each card; it is what you scroll through.' },
|
|
20
|
+
],
|
|
21
|
+
reducedMotion: 'Cards stack without shrinking or dimming.',
|
|
22
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
23
|
+
craft: [
|
|
24
|
+
'A card cannot read its own stuck position, so each card animates on the next card\'s timeline: the shrink is driven by the thing covering it, which is what the eye expects.',
|
|
25
|
+
'timeline-scope on the container hoists eight named timelines so siblings can reference each other.',
|
|
26
|
+
'transform-origin at the top edge, so a shrinking card recedes under the incoming one instead of floating.',
|
|
27
|
+
'Give every card an opaque background; sticky stacking only reads when the lower card is hidden.',
|
|
28
|
+
],
|
|
29
|
+
related: ['parallax', 'scroll-scrub', 'reveal'],
|
|
30
|
+
intents: ['card stack scroll', 'sticky stacking cards', 'stacked cards on scroll', 'apple style card stack', 'scroll stack astro'],
|
|
31
|
+
replaces: ['ScrollStack (React Bits)', 'StickyScroll (Aceternity)', 'GSAP pinned card stacks'],
|
|
32
|
+
fixture: { slot: '<div>Fixture content for CardStack</div><div>Two</div>', expect: 'Fixture content for CardStack' },
|
|
33
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Carousel — scroll-snap strip with native buttons and markers, zero JS.
|
|
4
|
+
*
|
|
5
|
+
* Children are the slides. Scrolling, snapping, momentum and touch are the
|
|
6
|
+
* browser's. Where ::scroll-button() and ::scroll-marker exist (Chromium),
|
|
7
|
+
* previous/next buttons and dots are generated pseudo-elements that scroll
|
|
8
|
+
* the container and track the current slide; elsewhere the strip is still
|
|
9
|
+
* swipeable and keyboard-scrollable, without the chrome.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
12
|
+
|
|
13
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
14
|
+
/** Slides visible at once. */
|
|
15
|
+
perView?: number;
|
|
16
|
+
/** Gap between slides, any CSS length. */
|
|
17
|
+
gap?: string;
|
|
18
|
+
/** Dot markers under the strip. */
|
|
19
|
+
markers?: boolean;
|
|
20
|
+
/** Previous / next buttons. */
|
|
21
|
+
buttons?: boolean;
|
|
22
|
+
/** Snap strictness. */
|
|
23
|
+
snap?: 'mandatory' | 'proximity';
|
|
24
|
+
/** Accessible name for the strip. */
|
|
25
|
+
label?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { perView = 1, gap = '1rem', markers = true, buttons = true, snap = 'mandatory', label = 'Carousel', class: className, style, ...rest } = Astro.props;
|
|
29
|
+
const vars = [`--ma-car-n:${perView}`, `--ma-car-gap:${gap}`, `--ma-car-snap:${snap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<div class:list={['ma-carousel', className]} data-markers={markers ? '' : undefined} data-buttons={buttons ? '' : undefined} style={vars} {...rest}>
|
|
33
|
+
<div class="ma-carousel__track" role="region" aria-label={label} tabindex="0">
|
|
34
|
+
<slot />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<style is:global>
|
|
39
|
+
@layer components {
|
|
40
|
+
:where(.ma-carousel) {
|
|
41
|
+
position: relative;
|
|
42
|
+
}
|
|
43
|
+
:where(.ma-carousel__track) {
|
|
44
|
+
display: grid;
|
|
45
|
+
grid-auto-flow: column;
|
|
46
|
+
grid-auto-columns: calc((100% - (var(--ma-car-n, 1) - 1) * var(--ma-car-gap, 1rem)) / var(--ma-car-n, 1));
|
|
47
|
+
gap: var(--ma-car-gap, 1rem);
|
|
48
|
+
overflow-x: auto;
|
|
49
|
+
overscroll-behavior-x: contain;
|
|
50
|
+
scroll-snap-type: x var(--ma-car-snap, mandatory);
|
|
51
|
+
scroll-behavior: smooth;
|
|
52
|
+
scrollbar-width: none;
|
|
53
|
+
scroll-marker-group: after;
|
|
54
|
+
}
|
|
55
|
+
:where(.ma-carousel__track)::-webkit-scrollbar {
|
|
56
|
+
display: none;
|
|
57
|
+
}
|
|
58
|
+
:where(.ma-carousel__track:focus-visible) {
|
|
59
|
+
outline: 2px solid currentColor;
|
|
60
|
+
outline-offset: 4px;
|
|
61
|
+
border-radius: 0.5rem;
|
|
62
|
+
}
|
|
63
|
+
:where(.ma-carousel__track > *) {
|
|
64
|
+
scroll-snap-align: start;
|
|
65
|
+
scroll-snap-stop: always;
|
|
66
|
+
}
|
|
67
|
+
/* buttons — generated, and they scroll the container by one page. Plain absolute insets against the
|
|
68
|
+
positioned root: the pseudo-elements sit outside the track's scrollable overflow, so they never scroll
|
|
69
|
+
with the slides, and no anchor is needed (anchoring a pseudo-element to its own container is not
|
|
70
|
+
reliably supported, and the two buttons then land on top of each other). */
|
|
71
|
+
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*) {
|
|
72
|
+
position: absolute;
|
|
73
|
+
top: 50%;
|
|
74
|
+
translate: 0 -50%;
|
|
75
|
+
z-index: 1;
|
|
76
|
+
display: grid;
|
|
77
|
+
place-items: center;
|
|
78
|
+
width: 2.25rem;
|
|
79
|
+
height: 2.25rem;
|
|
80
|
+
border-radius: 999px;
|
|
81
|
+
border: 1px solid var(--ma-edge);
|
|
82
|
+
background: var(--ma-panel);
|
|
83
|
+
color: inherit;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
font: inherit;
|
|
86
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out), translate var(--ma-duration-fast) var(--ma-ease-out);
|
|
87
|
+
}
|
|
88
|
+
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*):disabled {
|
|
89
|
+
opacity: 0.3;
|
|
90
|
+
cursor: default;
|
|
91
|
+
}
|
|
92
|
+
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*):hover:not(:disabled) {
|
|
93
|
+
translate: 0 calc(-50% - 1px);
|
|
94
|
+
}
|
|
95
|
+
/* with markers the slides sit above a marker row: centre on the slides, not on the whole box */
|
|
96
|
+
:where(.ma-carousel[data-buttons][data-markers] .ma-carousel__track)::scroll-button(*) {
|
|
97
|
+
top: calc(50% - 0.6875rem);
|
|
98
|
+
}
|
|
99
|
+
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(inline-start) {
|
|
100
|
+
content: var(--ma-car-prev, '←') / 'Previous';
|
|
101
|
+
inset-inline-start: 0.5rem;
|
|
102
|
+
}
|
|
103
|
+
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(inline-end) {
|
|
104
|
+
content: var(--ma-car-next, '→') / 'Next';
|
|
105
|
+
inset-inline-end: 0.5rem;
|
|
106
|
+
}
|
|
107
|
+
/* right to left the inline-start button is the right-hand one, so the arrows turn with it. [dir] and not :dir():
|
|
108
|
+
:dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never
|
|
109
|
+
matches. An ltr island one level inside an rtl page is set back */
|
|
110
|
+
:where([dir='rtl'] .ma-carousel, .ma-carousel[dir='rtl']) {
|
|
111
|
+
--ma-car-prev: '→';
|
|
112
|
+
--ma-car-next: '←';
|
|
113
|
+
}
|
|
114
|
+
:where([dir='rtl'] [dir='ltr'] .ma-carousel, [dir='rtl'] .ma-carousel[dir='ltr']) {
|
|
115
|
+
--ma-car-prev: '←';
|
|
116
|
+
--ma-car-next: '→';
|
|
117
|
+
}
|
|
118
|
+
/* markers — one per slide, current one filled */
|
|
119
|
+
:where(.ma-carousel[data-markers] .ma-carousel__track)::scroll-marker-group {
|
|
120
|
+
display: flex;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
padding-top: 0.875rem;
|
|
123
|
+
}
|
|
124
|
+
/* a 24 px target (WCAG 2.5.8) around the 8 px dot: the padding is hit area, the colour stays on the content box, the
|
|
125
|
+
negative block margin keeps the row as tall as the dot, and the targets sit edge to edge */
|
|
126
|
+
:where(.ma-carousel[data-markers] .ma-carousel__track > *)::scroll-marker {
|
|
127
|
+
content: '';
|
|
128
|
+
width: 0.5rem;
|
|
129
|
+
height: 0.5rem;
|
|
130
|
+
padding: max(0.5rem, calc(12px - 0.25rem));
|
|
131
|
+
margin-block: calc(-1 * max(0.5rem, calc(12px - 0.25rem)));
|
|
132
|
+
border-radius: 999px;
|
|
133
|
+
background: var(--ma-edge);
|
|
134
|
+
background-clip: content-box;
|
|
135
|
+
transition: background-color var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration-fast) var(--ma-ease-out);
|
|
136
|
+
}
|
|
137
|
+
:where(.ma-carousel[data-markers] .ma-carousel__track > *)::scroll-marker:target-current {
|
|
138
|
+
background-color: currentColor;
|
|
139
|
+
scale: 1.25;
|
|
140
|
+
}
|
|
141
|
+
@media (prefers-reduced-motion: reduce) {
|
|
142
|
+
:where(.ma-carousel__track) {
|
|
143
|
+
scroll-behavior: auto;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Carousel',
|
|
5
|
+
slug: 'carousel',
|
|
6
|
+
title: 'Carousel',
|
|
7
|
+
description: 'A scroll-snap strip with previous/next buttons and dot markers that the browser generates and keeps in sync. Zero JavaScript. Touch, momentum, keyboard and snapping are native everywhere; the buttons and dots appear where ::scroll-button() and ::scroll-marker exist.',
|
|
8
|
+
summary: 'A scroll-snap carousel with native touch, momentum and keyboard, plus browser-made buttons and dots where supported.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['click', 'pointer'],
|
|
12
|
+
support: { baseline: 'limited', needs: ['scroll-snap', 'scroll-marker'], fallback: 'a swipeable, keyboard-scrollable snap strip without buttons or dots (Firefox, Safari)' },
|
|
13
|
+
readout: '<Carousel perView={3}>',
|
|
14
|
+
usage: `<Carousel perView={3} gap="1rem" label="Testimonials">\n <figure>…</figure>\n <figure>…</figure>\n <figure>…</figure>\n</Carousel>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'perView', type: 'number', default: '1', description: 'Slides visible at once.' },
|
|
17
|
+
{ name: 'gap', type: 'string', default: "'1rem'", description: 'Gap between slides.' },
|
|
18
|
+
{ name: 'markers', type: 'boolean', default: 'true', description: 'Dot markers under the strip (where supported).' },
|
|
19
|
+
{ name: 'buttons', type: 'boolean', default: 'true', description: 'Previous / next buttons (where supported).' },
|
|
20
|
+
{ name: 'snap', type: "'mandatory' | 'proximity'", default: "'mandatory'", description: 'Snap strictness.' },
|
|
21
|
+
{ name: 'label', type: 'string', default: "'Carousel'", description: 'Accessible name of the scrolling region.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'Buttons and markers jump instead of smooth-scrolling; the strip still snaps.',
|
|
24
|
+
clientRouter: 'CSS-only; scroll position resets with the page.',
|
|
25
|
+
craft: [
|
|
26
|
+
'The strip is a grid with auto-flow columns: slide width is one expression of perView and gap, no measuring.',
|
|
27
|
+
'Buttons are ::scroll-button(): they scroll by a page, disable at the ends, and need no event handler. They are absolutely positioned against the carousel at its inline edges, so they never scroll with the slides, and in a right-to-left page they swap sides and their arrows turn.',
|
|
28
|
+
'Markers are ::scroll-marker on each slide; :target-current is the browser telling you which slide is in view. Each dot is 8 px inside a 24 px target, the WCAG 2.5.8 minimum.',
|
|
29
|
+
'scroll-snap-stop: always so a fast flick never skips a slide.',
|
|
30
|
+
],
|
|
31
|
+
related: ['tabs', 'marquee', 'card-stack'],
|
|
32
|
+
intents: ['carousel', 'slider', 'scroll snap carousel', 'image carousel astro', 'carousel without javascript', 'testimonial slider'],
|
|
33
|
+
replaces: ['Embla', 'Swiper for simple strips', 'Carousel (shadcn)', 'Carousel (React Bits)'],
|
|
34
|
+
fixture: { slot: '<div>Fixture content for Carousel</div><div>Two</div><div>Three</div>', expect: 'Fixture content for Carousel' },
|
|
35
|
+
} satisfies ComponentMeta;
|