@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,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Switch — a toggle that springs, zero JS. It *is* a checkbox: `<input
|
|
4
|
+
* type="checkbox" role="switch">` with `appearance: none`, the input itself
|
|
5
|
+
* drawn as the track and its ::before as the thumb, so the value submits,
|
|
6
|
+
* `required` and `disabled` work, Space flips it and a form library sees a
|
|
7
|
+
* plain checkbox. The thumb travels on `translate` with the preset curve
|
|
8
|
+
* (snap and lively are sampled springs, so it lands with a bounce); the
|
|
9
|
+
* track colour crosses on the fast token. WebKit's native `switch`
|
|
10
|
+
* attribute is set too, but nothing here depends on it. Wrapped in a
|
|
11
|
+
* <label> when `label` is given; otherwise pass `aria-label`. Classes are
|
|
12
|
+
* `.ma-toggle` — `.ma-switch` was already taken.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
15
|
+
|
|
16
|
+
interface Props extends Omit<HTMLAttributes<'input'>, 'type' | 'size'> {
|
|
17
|
+
/** Form field name. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** Submitted value when on. */
|
|
20
|
+
value?: string;
|
|
21
|
+
checked?: boolean;
|
|
22
|
+
/** Visible label; without it, pass aria-label. */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** Track height: 1.25rem or 1.5rem. */
|
|
25
|
+
size?: 'sm' | 'md';
|
|
26
|
+
/** Track colour when on; default the ink token. */
|
|
27
|
+
color?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { name, value = 'on', checked = false, label, size = 'md', color, class: className, style, ...rest } = Astro.props;
|
|
31
|
+
const vars = [color && `--ma-toggle-on:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
32
|
+
// the WebKit native switch attribute: harmless elsewhere, and appearance: none overrides its look everywhere
|
|
33
|
+
const native = { switch: '' };
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<label class:list={['ma-toggle', className]} data-size={size} style={vars || undefined}>
|
|
37
|
+
<input type="checkbox" role="switch" class="ma-toggle__input" name={name} value={value} checked={checked} {...native} {...rest} />
|
|
38
|
+
{label && <span class="ma-toggle__label">{label}</span>}
|
|
39
|
+
</label>
|
|
40
|
+
|
|
41
|
+
<style is:global>
|
|
42
|
+
@layer components {
|
|
43
|
+
:where(.ma-toggle) {
|
|
44
|
+
--ma-toggle-h: 1.5rem;
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 0.6em;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
-webkit-tap-highlight-color: transparent;
|
|
50
|
+
}
|
|
51
|
+
:where(.ma-toggle[data-size='sm']) {
|
|
52
|
+
--ma-toggle-h: 1.25rem;
|
|
53
|
+
}
|
|
54
|
+
:where(.ma-toggle__input) {
|
|
55
|
+
/* the track: the input itself, so the value, the focus and the keyboard are the browser's */
|
|
56
|
+
appearance: none;
|
|
57
|
+
-webkit-appearance: none;
|
|
58
|
+
position: relative;
|
|
59
|
+
flex: none;
|
|
60
|
+
inline-size: calc(var(--ma-toggle-h) * 1.75);
|
|
61
|
+
block-size: var(--ma-toggle-h);
|
|
62
|
+
margin: 0;
|
|
63
|
+
border: 1px solid var(--ma-edge);
|
|
64
|
+
border-radius: 999px;
|
|
65
|
+
background: color-mix(in srgb, var(--ma-ink) 14%, transparent);
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
font: inherit;
|
|
68
|
+
transition:
|
|
69
|
+
background-color var(--ma-duration-fast) var(--ma-ease-out),
|
|
70
|
+
border-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
71
|
+
}
|
|
72
|
+
:where(.ma-toggle__input)::before {
|
|
73
|
+
/* the thumb: a pseudo-element, travelling the track width minus itself on the preset spring */
|
|
74
|
+
content: '';
|
|
75
|
+
position: absolute;
|
|
76
|
+
inset-block-start: 50%;
|
|
77
|
+
inset-inline-start: 0;
|
|
78
|
+
inline-size: calc(var(--ma-toggle-h) - 6px);
|
|
79
|
+
block-size: calc(var(--ma-toggle-h) - 6px);
|
|
80
|
+
margin-block-start: calc((var(--ma-toggle-h) - 6px) / -2);
|
|
81
|
+
margin-inline-start: 2px;
|
|
82
|
+
border-radius: 50%;
|
|
83
|
+
background: var(--ma-panel);
|
|
84
|
+
box-shadow: 0 1px 2px var(--ma-glow);
|
|
85
|
+
translate: 0 0;
|
|
86
|
+
transition: translate var(--ma-duration) var(--ma-ease);
|
|
87
|
+
}
|
|
88
|
+
/* the target: whatever the track's size, the input answers a press within 24 × 24 px (WCAG 2.2) — a pseudo-element of the input is part of it for hit testing */
|
|
89
|
+
:where(.ma-toggle__input)::after {
|
|
90
|
+
content: '';
|
|
91
|
+
position: absolute;
|
|
92
|
+
inset: min(0px, calc((var(--ma-toggle-h) - 26px) / 2)) min(0px, calc((var(--ma-toggle-h) * 1.75 - 26px) / 2));
|
|
93
|
+
}
|
|
94
|
+
:where(.ma-toggle__input:checked) {
|
|
95
|
+
background: var(--ma-toggle-on, var(--ma-ink));
|
|
96
|
+
border-color: var(--ma-toggle-on, var(--ma-ink));
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-toggle__input:checked)::before {
|
|
99
|
+
translate: calc(var(--ma-toggle-h) * 0.75 * var(--ma-toggle-dir, 1)) 0;
|
|
100
|
+
}
|
|
101
|
+
/* right to left: the thumb starts at the right end (inset-inline-start), so on is a move to the left, and translate
|
|
102
|
+
has no logical form. [dir] and not :dir(): Chrome has :dir() only from 120, and a build that targets an older
|
|
103
|
+
Chrome rewrites it into a guess from lang, which a page that sets only dir never matches. An ltr island one level
|
|
104
|
+
inside an rtl page is set back */
|
|
105
|
+
:where([dir='rtl'] .ma-toggle, .ma-toggle__input[dir='rtl']) {
|
|
106
|
+
--ma-toggle-dir: -1;
|
|
107
|
+
}
|
|
108
|
+
:where([dir='rtl'] [dir='ltr'] .ma-toggle, [dir='rtl'] .ma-toggle__input[dir='ltr']) {
|
|
109
|
+
--ma-toggle-dir: 1;
|
|
110
|
+
}
|
|
111
|
+
:where(.ma-toggle__input:focus-visible) {
|
|
112
|
+
outline: 2px solid var(--ma-toggle-on, var(--ma-ink));
|
|
113
|
+
outline-offset: 3px;
|
|
114
|
+
}
|
|
115
|
+
:where(.ma-toggle__input:disabled) {
|
|
116
|
+
cursor: not-allowed;
|
|
117
|
+
opacity: 0.45;
|
|
118
|
+
transition: none;
|
|
119
|
+
}
|
|
120
|
+
:where(.ma-toggle__input:disabled)::before {
|
|
121
|
+
transition: none;
|
|
122
|
+
}
|
|
123
|
+
:where(.ma-toggle:has(.ma-toggle__input:disabled)) {
|
|
124
|
+
cursor: not-allowed;
|
|
125
|
+
}
|
|
126
|
+
:where(.ma-toggle:has(.ma-toggle__input:disabled) .ma-toggle__label) {
|
|
127
|
+
opacity: 0.6;
|
|
128
|
+
}
|
|
129
|
+
:where(.ma-toggle__label) {
|
|
130
|
+
line-height: 1.2;
|
|
131
|
+
user-select: none;
|
|
132
|
+
}
|
|
133
|
+
/* Windows high contrast: the track is a stroke, the thumb a filled dot, on = Highlight */
|
|
134
|
+
@media (forced-colors: active) {
|
|
135
|
+
:where(.ma-toggle__input) {
|
|
136
|
+
border-color: ButtonText;
|
|
137
|
+
background: Canvas;
|
|
138
|
+
}
|
|
139
|
+
:where(.ma-toggle__input)::before {
|
|
140
|
+
background: ButtonText;
|
|
141
|
+
box-shadow: none;
|
|
142
|
+
}
|
|
143
|
+
:where(.ma-toggle__input:checked) {
|
|
144
|
+
background: Highlight;
|
|
145
|
+
border-color: Highlight;
|
|
146
|
+
}
|
|
147
|
+
:where(.ma-toggle__input:checked)::before {
|
|
148
|
+
background: HighlightText;
|
|
149
|
+
}
|
|
150
|
+
:where(.ma-toggle__input:disabled) {
|
|
151
|
+
border-color: GrayText;
|
|
152
|
+
opacity: 1;
|
|
153
|
+
}
|
|
154
|
+
:where(.ma-toggle__input:disabled)::before {
|
|
155
|
+
background: GrayText;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
@media (prefers-reduced-motion: reduce) {
|
|
159
|
+
:where(.ma-toggle__input),
|
|
160
|
+
:where(.ma-toggle__input)::before {
|
|
161
|
+
transition: none;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Switch',
|
|
5
|
+
slug: 'switch',
|
|
6
|
+
title: 'Switch',
|
|
7
|
+
description: 'A toggle that springs. It is a native checkbox with role="switch" and appearance: none (the input is the track, its ::before the thumb), so the value submits, required and disabled work, Space flips it, and the thumb travels on the preset curve with a bounce under the snap and lively presets. Zero JavaScript.',
|
|
8
|
+
summary: 'A toggle switch with a springy thumb on a native checkbox, so it submits with the form and Space flips it.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<Switch name="alerts" label="Email me" checked>',
|
|
14
|
+
usage: `<Switch name="alerts" label="Email me about releases" checked />\n<Switch name="beta" label="Beta features" size="sm" />\n\n<!-- no visible label: name it for assistive tech -->\n<Switch name="dark" aria-label="Dark mode" />`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'name', type: 'string', description: 'Form field name; the value is sent when the switch is on.' },
|
|
17
|
+
{ name: 'value', type: 'string', default: "'on'", description: 'Submitted value.' },
|
|
18
|
+
{ name: 'checked', type: 'boolean', default: 'false', description: 'Initial state.' },
|
|
19
|
+
{ name: 'label', type: 'string', description: 'Visible label, wrapping the input in a <label>. Without it, pass aria-label.' },
|
|
20
|
+
{ name: 'size', type: "'sm' | 'md'", default: "'md'", description: 'Track height: 1.25 rem or 1.5 rem; the thumb and the travel follow.' },
|
|
21
|
+
{ name: 'color', type: 'string', description: 'Track colour when on; default the ink token.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'The thumb jumps and the track colour changes at once.',
|
|
24
|
+
clientRouter: 'A native input; its state is in the DOM and goes with the page. Nothing to bind.',
|
|
25
|
+
craft: [
|
|
26
|
+
'The input is the control. appearance: none removes the native drawing and leaves everything else: the value in FormData, required, disabled, the label association, Space and the accessibility tree (role="switch" on a checkbox is the pattern the ARIA spec names). No hidden input, no custom element.',
|
|
27
|
+
'The thumb is a pseudo-element of the input translated by track width minus its own, on the preset curve: under snap or lively that curve is a sampled spring, so the thumb overshoots and settles; under ui it eases. The track colour crosses on the fast token, so it has changed before the thumb arrives.',
|
|
28
|
+
'Right to left, the thumb starts at the right end and on moves it left: its offsets are logical, and the travel flips under a dir="rtl" ancestor. The selector reads the attribute because Chrome has :dir() only from 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never matches. An ltr island one level inside a right-to-left page is set back.',
|
|
29
|
+
'WebKit\'s switch attribute is set as well, so a browser that wants to render it natively may, but the stylesheet never depends on it; appearance: none wins in every engine.',
|
|
30
|
+
'Windows high contrast (forced-colors) gets its own rules: a ButtonText stroke for the track, a filled dot, Highlight when on. A control drawn with appearance: none is invisible there without them.',
|
|
31
|
+
'Focus is the ring the platform draws on :focus-visible, on the ink colour, offset from the track. Disabled halves the opacity and drops the transition.',
|
|
32
|
+
],
|
|
33
|
+
related: ['check-draw', 'price-switch', 'theme-toggle'],
|
|
34
|
+
intents: ['toggle switch', 'animated switch', 'checkbox as switch', 'ios style toggle', 'switch with spring', 'settings toggle'],
|
|
35
|
+
replaces: ['Switch (Motion Primitives)', 'Switch (shadcn / Radix)', 'Toggle (Tailwind Plus)'],
|
|
36
|
+
fixture: { props: { name: 'ma-fixture', label: 'Fixture content for Switch' }, expect: 'Fixture content for Switch' },
|
|
37
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Tabs — a sliding indicator between tabs, zero JS.
|
|
4
|
+
*
|
|
5
|
+
* Tabs are radio inputs (arrow keys, focus, one selected: native). :has()
|
|
6
|
+
* turns the checked index into a custom property; the indicator translates
|
|
7
|
+
* by it on the active preset's curve and the matching panel is shown with a
|
|
8
|
+
* @starting-style entrance. Up to eight tabs. `direction` lays the panels
|
|
9
|
+
* out as one horizontal track that translates by the same index, so the
|
|
10
|
+
* panel slides the way you moved — 3 → 1 comes in from the left — with no
|
|
11
|
+
* knowledge of the previous tab; the track is as tall as its tallest panel
|
|
12
|
+
* and the panels off-screen are visibility: hidden once the slide ends.
|
|
13
|
+
* Right to left, tab 1 sits on the right and both movements run the other
|
|
14
|
+
* way. A panel is its tab's `body` (an HTML string) or, for components and
|
|
15
|
+
* images, the static slot `panel-1` … `panel-8`, which wins over the body.
|
|
16
|
+
*/
|
|
17
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
18
|
+
|
|
19
|
+
interface Tab {
|
|
20
|
+
label: string;
|
|
21
|
+
/** HTML string. The slot `panel-<n>` (n from 1) wins over it. */
|
|
22
|
+
body?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
26
|
+
tabs: Tab[];
|
|
27
|
+
/** Radio group name and id prefix; unique per page. */
|
|
28
|
+
id: string;
|
|
29
|
+
/** Accessible name of the group, e.g. "Package manager". */
|
|
30
|
+
label?: string;
|
|
31
|
+
/** Index selected on load. */
|
|
32
|
+
active?: number;
|
|
33
|
+
/** Panels slide sideways in the direction of the change instead of fading in place. */
|
|
34
|
+
direction?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { tabs, id, label, active = 0, direction = false, class: className, style, ...rest } = Astro.props;
|
|
38
|
+
const list = tabs.slice(0, 8);
|
|
39
|
+
// the caller's style joins the tab count instead of replacing it (the columns and the indicator's width read it)
|
|
40
|
+
const vars = [`--ma-tabs-n:${list.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<div class:list={['ma-tabs', className]} data-direction={direction ? '' : undefined} style={vars} {...rest}>
|
|
44
|
+
<div class="ma-tabs__list" role="radiogroup" aria-label={label}>
|
|
45
|
+
{
|
|
46
|
+
list.map((t, i) => (
|
|
47
|
+
<>
|
|
48
|
+
<input type="radio" name={id} id={`${id}-${i}`} class="ma-tabs__input" checked={i === active} />
|
|
49
|
+
<label for={`${id}-${i}`} class="ma-tabs__tab">{t.label}</label>
|
|
50
|
+
</>
|
|
51
|
+
))
|
|
52
|
+
}
|
|
53
|
+
<span class="ma-tabs__indicator" aria-hidden="true"></span>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="ma-tabs__panels">
|
|
56
|
+
{/* slot names are static (a name built in the map is not a slot Astro can fill): eight lines, one per tab */}
|
|
57
|
+
{list.map((t, i) => (
|
|
58
|
+
<div class="ma-tabs__panel">
|
|
59
|
+
{i === 0 && <slot name="panel-1"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
60
|
+
{i === 1 && <slot name="panel-2"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
61
|
+
{i === 2 && <slot name="panel-3"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
62
|
+
{i === 3 && <slot name="panel-4"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
63
|
+
{i === 4 && <slot name="panel-5"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
64
|
+
{i === 5 && <slot name="panel-6"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
65
|
+
{i === 6 && <slot name="panel-7"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
66
|
+
{i === 7 && <slot name="panel-8"><Fragment set:html={t.body ?? ''} /></slot>}
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<style is:global>
|
|
73
|
+
@layer components {
|
|
74
|
+
:where(.ma-tabs__list) {
|
|
75
|
+
position: relative;
|
|
76
|
+
display: grid;
|
|
77
|
+
grid-template-columns: repeat(var(--ma-tabs-n, 2), 1fr);
|
|
78
|
+
border-bottom: 1px solid var(--ma-edge);
|
|
79
|
+
}
|
|
80
|
+
:where(.ma-tabs__input) {
|
|
81
|
+
position: absolute;
|
|
82
|
+
width: 1px;
|
|
83
|
+
height: 1px;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
clip-path: inset(50%);
|
|
86
|
+
white-space: nowrap;
|
|
87
|
+
}
|
|
88
|
+
:where(.ma-tabs__tab) {
|
|
89
|
+
padding: 0.625rem 0.75rem;
|
|
90
|
+
text-align: center;
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
opacity: var(--ma-dim, 0.7);
|
|
93
|
+
font-weight: 500;
|
|
94
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
95
|
+
}
|
|
96
|
+
:where(.ma-tabs__tab:hover),
|
|
97
|
+
:where(.ma-tabs__input:checked + .ma-tabs__tab) {
|
|
98
|
+
opacity: 1;
|
|
99
|
+
}
|
|
100
|
+
:where(.ma-tabs__input:focus-visible + .ma-tabs__tab) {
|
|
101
|
+
outline: 2px solid currentColor;
|
|
102
|
+
outline-offset: -2px;
|
|
103
|
+
border-radius: 0.375rem;
|
|
104
|
+
}
|
|
105
|
+
:where(.ma-tabs__indicator) {
|
|
106
|
+
position: absolute;
|
|
107
|
+
inset-inline-start: 0;
|
|
108
|
+
bottom: -1px;
|
|
109
|
+
width: calc(100% / var(--ma-tabs-n, 2));
|
|
110
|
+
height: 2px;
|
|
111
|
+
background: currentColor;
|
|
112
|
+
translate: calc(var(--ma-tab, 0) * 100% * var(--ma-tabs-dir, 1)) 0;
|
|
113
|
+
transition: translate var(--ma-duration) var(--ma-ease);
|
|
114
|
+
}
|
|
115
|
+
/* Right to left the first tab is on the right, so the indicator (and the direction track) translate the other
|
|
116
|
+
way. [dir] and not :dir(): Chrome has :dir() only from 120, and a build that targets older engines rewrites it
|
|
117
|
+
into a guess from lang, which a page that sets only dir never matches. An ltr island one level inside an rtl
|
|
118
|
+
page is set back. */
|
|
119
|
+
:where([dir='rtl'] .ma-tabs, .ma-tabs[dir='rtl']) { --ma-tabs-dir: -1; }
|
|
120
|
+
:where([dir='rtl'] [dir='ltr'] .ma-tabs, [dir='rtl'] .ma-tabs[dir='ltr']) { --ma-tabs-dir: 1; }
|
|
121
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(2):checked)) { --ma-tab: 1; }
|
|
122
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(3):checked)) { --ma-tab: 2; }
|
|
123
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(4):checked)) { --ma-tab: 3; }
|
|
124
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(5):checked)) { --ma-tab: 4; }
|
|
125
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(6):checked)) { --ma-tab: 5; }
|
|
126
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(7):checked)) { --ma-tab: 6; }
|
|
127
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(8):checked)) { --ma-tab: 7; }
|
|
128
|
+
:where(.ma-tabs__panel) {
|
|
129
|
+
display: none;
|
|
130
|
+
padding-top: 1rem;
|
|
131
|
+
opacity: 1;
|
|
132
|
+
translate: 0 0;
|
|
133
|
+
transition: opacity var(--ma-duration) var(--ma-ease), translate var(--ma-duration) var(--ma-ease);
|
|
134
|
+
}
|
|
135
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(1):checked) .ma-tabs__panel:nth-child(1)),
|
|
136
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(2):checked) .ma-tabs__panel:nth-child(2)),
|
|
137
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(3):checked) .ma-tabs__panel:nth-child(3)),
|
|
138
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(4):checked) .ma-tabs__panel:nth-child(4)),
|
|
139
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(5):checked) .ma-tabs__panel:nth-child(5)),
|
|
140
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(6):checked) .ma-tabs__panel:nth-child(6)),
|
|
141
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(7):checked) .ma-tabs__panel:nth-child(7)),
|
|
142
|
+
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(8):checked) .ma-tabs__panel:nth-child(8)) {
|
|
143
|
+
display: block;
|
|
144
|
+
}
|
|
145
|
+
@starting-style {
|
|
146
|
+
:where(.ma-tabs__panel) {
|
|
147
|
+
opacity: 0;
|
|
148
|
+
translate: 0 var(--ma-travel-hover, 4px);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/* direction: every panel is laid out in one row of equal columns; the row translates by the checked index,
|
|
152
|
+
so the slide goes the way the selection moved. Off-screen panels hide once the slide has ended (visibility
|
|
153
|
+
interpolates as visible for the whole transition) so focus never lands in them. The clip sits on the root:
|
|
154
|
+
a row that clips itself and then translates carries its clip along, and every panel after the first was cut
|
|
155
|
+
away. */
|
|
156
|
+
:where(.ma-tabs[data-direction]) {
|
|
157
|
+
overflow-x: clip;
|
|
158
|
+
}
|
|
159
|
+
:where(.ma-tabs[data-direction] .ma-tabs__panels) {
|
|
160
|
+
display: grid;
|
|
161
|
+
grid-auto-flow: column;
|
|
162
|
+
grid-auto-columns: 100%;
|
|
163
|
+
translate: calc(var(--ma-tab, 0) * -100% * var(--ma-tabs-dir, 1)) 0;
|
|
164
|
+
transition: translate var(--ma-duration) var(--ma-ease);
|
|
165
|
+
}
|
|
166
|
+
:where(.ma-tabs[data-direction] .ma-tabs__panel) {
|
|
167
|
+
display: block;
|
|
168
|
+
visibility: hidden;
|
|
169
|
+
opacity: 0.4;
|
|
170
|
+
translate: 0 0;
|
|
171
|
+
transition:
|
|
172
|
+
visibility var(--ma-duration),
|
|
173
|
+
opacity var(--ma-duration) var(--ma-ease);
|
|
174
|
+
}
|
|
175
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(1):checked) .ma-tabs__panel:nth-child(1)),
|
|
176
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(2):checked) .ma-tabs__panel:nth-child(2)),
|
|
177
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(3):checked) .ma-tabs__panel:nth-child(3)),
|
|
178
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(4):checked) .ma-tabs__panel:nth-child(4)),
|
|
179
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(5):checked) .ma-tabs__panel:nth-child(5)),
|
|
180
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(6):checked) .ma-tabs__panel:nth-child(6)),
|
|
181
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(7):checked) .ma-tabs__panel:nth-child(7)),
|
|
182
|
+
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(8):checked) .ma-tabs__panel:nth-child(8)) {
|
|
183
|
+
visibility: visible;
|
|
184
|
+
opacity: 1;
|
|
185
|
+
}
|
|
186
|
+
@media (prefers-reduced-motion: reduce) {
|
|
187
|
+
:where(.ma-tabs__indicator),
|
|
188
|
+
:where(.ma-tabs__panel),
|
|
189
|
+
:where(.ma-tabs[data-direction] .ma-tabs__panels) {
|
|
190
|
+
transition: none;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Tabs',
|
|
5
|
+
slug: 'tabs',
|
|
6
|
+
title: 'Tabs',
|
|
7
|
+
description: 'Tabs with an indicator that slides to the selected one and panels that fade in. Zero JavaScript: the tabs are radio inputs, so keyboard and selection are native, and :has() turns the checked index into the indicator\'s position.',
|
|
8
|
+
summary: 'Tabs with an indicator that slides to the selected tab and panels that fade in, keyboard included.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'widely', needs: ['has', 'starting-style'], fallback: 'panels switch without the fade (or the slide)' },
|
|
13
|
+
readout: '<Tabs direction>',
|
|
14
|
+
usage: `<Tabs\n id="install"\n tabs={[\n { label: 'npm', body: '<pre>npx astro add moonarc</pre>' },\n { label: 'pnpm', body: '<pre>pnpm astro add moonarc</pre>' },\n { label: 'copy', body: '<pre>npx shadcn@latest add …</pre>' },\n ]}\n/>\n\n<!-- panels slide the way you moved -->\n<Tabs id="features" direction tabs={features} />\n\n<!-- a panel that holds components: the slot of that tab -->\n<Tabs id="shots" label="Screens" tabs={[{ label: 'Board' }, { label: 'Inbox' }]}>\n <Image slot="panel-1" src={board} alt="The board with three columns" />\n <BrowserFrame slot="panel-2" url="app.example.com/inbox" src={inbox} alt="The inbox" />\n</Tabs>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'tabs', type: '{ label: string; body?: string }[]', description: 'Up to eight tabs; body is an HTML string. For a component, an optimised image or anything else that is not a string, leave body out and fill the slot panel-1 … panel-8 instead: a slot wins over the body.' },
|
|
17
|
+
{ name: 'id', type: 'string', description: 'Radio group name and id prefix; unique per page.' },
|
|
18
|
+
{ name: 'label', type: 'string', description: 'Accessible name of the radio group ("Package manager"); read before the first tab.' },
|
|
19
|
+
{ name: 'active', type: 'number', default: '0', description: 'Index selected on load.' },
|
|
20
|
+
{ name: 'direction', type: 'boolean', default: 'false', description: 'Panels slide sideways in the direction of the change (3 → 1 comes in from the left, and from the right under dir="rtl") instead of fading in place. The panels sit in one track as tall as the tallest of them.' },
|
|
21
|
+
],
|
|
22
|
+
reducedMotion: 'The indicator jumps; panels switch instantly.',
|
|
23
|
+
clientRouter: 'CSS-only; the selection lives in the radio inputs and resets with the page.',
|
|
24
|
+
craft: [
|
|
25
|
+
'Radios, not buttons with aria-selected: arrow keys, focus and exactly-one-selected are free. The group says so with role="radiogroup": a tablist promises aria-selected and roving focus that only a script can keep, and a screen reader would announce a tab it then cannot find.',
|
|
26
|
+
'Equal-width columns make the indicator a translate of whole widths: one custom property, no measuring. Under dir="rtl" the first tab is on the right and the translate changes sign; the switch reads the dir attribute, because Chrome has :dir() only from 120 and a build that targets older engines rewrites it into a guess from lang.',
|
|
27
|
+
'The indicator moves on the preset spring; the incoming panel travels 4 px (--ma-travel-hover), enough to read as arriving, too little to read as a slide.',
|
|
28
|
+
'Inputs are visually clipped and stay focusable; display: none would remove them from the tab order.',
|
|
29
|
+
'A panel is an HTML string or a named slot, panel-1 to panel-8. The slots are written out one per tab, because a slot name built in a loop is not one Astro can fill; so an astro:assets Image or a BrowserFrame goes in a panel with no string in between.',
|
|
30
|
+
'direction needs no memory of the previous tab: the panels are one horizontal track translated by the checked index, so the slide is always toward the selection. The clip sits on the component because a track that clipped itself would carry the clip along as it moved. Panels off screen turn visibility: hidden when the slide ends (visibility interpolates as visible for the whole transition), so Tab never lands in a hidden panel.',
|
|
31
|
+
],
|
|
32
|
+
related: ['accordion', 'carousel', 'popover'],
|
|
33
|
+
intents: ['tabs', 'animated tab indicator', 'sliding underline tabs', 'tabs without javascript', 'segmented control'],
|
|
34
|
+
replaces: ['Tabs (Radix / shadcn)', 'Tabs (Aceternity)', 'AnimatedTabs'],
|
|
35
|
+
fixture: {
|
|
36
|
+
props: { id: 'ma-fixture-tabs', tabs: [{ label: 'Fixture content for Tabs', body: '<p>One</p>' }, { label: 'Two', body: '<p>Two</p>' }] },
|
|
37
|
+
expect: 'Fixture content for Tabs',
|
|
38
|
+
// the slots: an image in panel 1 and a BrowserFrame in panel 2, no body; 0 B of JavaScript either way
|
|
39
|
+
variants: {
|
|
40
|
+
slots: {
|
|
41
|
+
props: { direction: true, tabs: [{ label: 'Fixture content for Tabs' }, { label: 'Two' }] },
|
|
42
|
+
slot: '<img slot="panel-1" src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2280%22 height=%2245%22/%3E" width="80" height="45" alt="A slotted image" /><BrowserFrame slot="panel-2" url="https://example.com/panel"><p>A slotted frame</p></BrowserFrame>',
|
|
43
|
+
expect: 'A slotted frame',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TextRoll — a label rolls up into a copy of itself on hover, one letter
|
|
4
|
+
* after another. Two copies of the text are stacked in one grid cell and
|
|
5
|
+
* clipped; each character translates by its own height on a per-index
|
|
6
|
+
* delay, so the roll runs left to right. Zero JS. Hover on the element or
|
|
7
|
+
* on the link or button that contains it.
|
|
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
|
+
/** Delay between neighbouring characters in ms. Unset: the preset's --ma-stagger-tight. */
|
|
17
|
+
step?: number;
|
|
18
|
+
/** Per-character duration in ms. Unset: the preset's --ma-duration. */
|
|
19
|
+
duration?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { text, as: Tag = 'span', step, duration, class: className, style, ...rest } = Astro.props;
|
|
23
|
+
// graphemes, not code points: an emoji with a skin tone or a letter with a combining accent rolls as one character
|
|
24
|
+
const chars = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment);
|
|
25
|
+
const vars = [step !== undefined && `--ma-roll-step:${step}ms`, duration !== undefined && `--ma-roll-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
26
|
+
// dir="auto": the characters are inline-blocks, which bidi orders by the page's direction, so Latin text on a
|
|
27
|
+
// right-to-left page rolled back to front. The element takes its direction from its own text instead.
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<Tag class:list={['ma-roll', className]} dir="auto" style={vars || undefined} {...rest}>
|
|
31
|
+
<span class="ma-roll__line" aria-hidden="true">{chars.map((c, i) => <span class="ma-roll__char" style={`--ma-i:${i}`}>{c}</span>)}</span>
|
|
32
|
+
<span class="ma-roll__line ma-roll__line--next" aria-hidden="true">{chars.map((c, i) => <span class="ma-roll__char" style={`--ma-i:${i}`}>{c}</span>)}</span>
|
|
33
|
+
<span class="ma-sr">{text}</span>
|
|
34
|
+
</Tag>
|
|
35
|
+
|
|
36
|
+
<style is:global>
|
|
37
|
+
@layer components {
|
|
38
|
+
:where(.ma-roll) {
|
|
39
|
+
display: inline-grid;
|
|
40
|
+
overflow: clip;
|
|
41
|
+
line-height: 1.15;
|
|
42
|
+
vertical-align: bottom;
|
|
43
|
+
}
|
|
44
|
+
:where(.ma-roll__line) {
|
|
45
|
+
grid-area: 1 / 1;
|
|
46
|
+
white-space: pre;
|
|
47
|
+
}
|
|
48
|
+
:where(.ma-roll__char) {
|
|
49
|
+
display: inline-block;
|
|
50
|
+
transition: translate var(--ma-roll-dur, var(--ma-duration)) var(--ma-ease);
|
|
51
|
+
transition-delay: calc(var(--ma-i, 0) * var(--ma-roll-step, var(--ma-stagger-tight)));
|
|
52
|
+
}
|
|
53
|
+
:where(.ma-roll__line--next .ma-roll__char) {
|
|
54
|
+
translate: 0 100%;
|
|
55
|
+
}
|
|
56
|
+
@media (hover: hover) {
|
|
57
|
+
:where(.ma-roll:hover .ma-roll__line .ma-roll__char),
|
|
58
|
+
:where(:is(a, button, label):hover .ma-roll .ma-roll__line .ma-roll__char) {
|
|
59
|
+
translate: 0 -100%;
|
|
60
|
+
}
|
|
61
|
+
:where(.ma-roll:hover .ma-roll__line--next .ma-roll__char),
|
|
62
|
+
:where(:is(a, button, label):hover .ma-roll .ma-roll__line--next .ma-roll__char) {
|
|
63
|
+
translate: 0 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
:where(:is(a, button):focus-visible .ma-roll .ma-roll__line .ma-roll__char) {
|
|
67
|
+
translate: 0 -100%;
|
|
68
|
+
}
|
|
69
|
+
:where(:is(a, button):focus-visible .ma-roll .ma-roll__line--next .ma-roll__char) {
|
|
70
|
+
translate: 0 0;
|
|
71
|
+
}
|
|
72
|
+
@media (prefers-reduced-motion: reduce) {
|
|
73
|
+
:where(.ma-roll__char) {
|
|
74
|
+
transition: none;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'TextRoll',
|
|
5
|
+
slug: 'text-roll',
|
|
6
|
+
title: 'Text Roll',
|
|
7
|
+
description:
|
|
8
|
+
'A label rolls upward into a copy of itself on hover, letter by letter from the left, and rolls back when the pointer leaves. Two copies in one clipped cell, one translate per character on a per-index delay. Pure CSS; works from the link or button around it.',
|
|
9
|
+
summary: 'A text roll hover effect: the label rolls up into a copy of itself letter by letter, and back when you leave.',
|
|
10
|
+
category: 'text',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['hover'],
|
|
13
|
+
support: { baseline: 'widely', needs: [] },
|
|
14
|
+
readout: '<TextRoll text="Get started">',
|
|
15
|
+
usage: `<a href="/setup/"><TextRoll text="Get started" /> →</a>\n<nav>{links.map((l) => <a href={l.href}><TextRoll text={l.label} step={20} /></a>)}</nav>`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'text', type: 'string', description: 'The text; rendered twice, so it must be a plain string.' },
|
|
18
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
19
|
+
{ name: 'step', type: 'number', default: 'preset (40)', description: 'Delay between neighbouring characters in ms. 15–30 reads as a ripple, 40+ as a wave.' },
|
|
20
|
+
{ name: 'duration', type: 'number', default: 'preset (ui: 450)', description: 'Per-character duration in ms; unset, the active preset\'s.' },
|
|
21
|
+
],
|
|
22
|
+
reducedMotion: 'The roll is removed; the label is static.',
|
|
23
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
24
|
+
craft: [
|
|
25
|
+
'The second copy is the same text, so the roll never changes what is read: it is a hover acknowledgement, not a reveal. That is why it can be fast.',
|
|
26
|
+
'Per-character delay of one stagger step, left to right, on the preset spring: the roll reads as one gesture running through the word.',
|
|
27
|
+
'Hover is read from the containing link or button too, so the whole target triggers it.',
|
|
28
|
+
'Each character is inline-block and translates by its own height inside a clipped grid cell: no measuring, no line-height dependency.',
|
|
29
|
+
'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.',
|
|
30
|
+
],
|
|
31
|
+
related: ['underline-hover', 'bubble-text', 'rotating-text'],
|
|
32
|
+
intents: ['text roll hover', 'rolling link text', 'hover text flip up', 'letter roll animation', 'text roll motion primitives'],
|
|
33
|
+
replaces: ['TextRoll (Motion Primitives)', 'link hover roll (Skiper, motion.dev)'],
|
|
34
|
+
// a letter with a combining accent rolls as one character (a grapheme): the é below is e + U+0301, two code points
|
|
35
|
+
fixture: { props: { text: 'Fixture content for TextRoll café' }, expect: 'style="--ma-i:32">é</span>' },
|
|
36
|
+
} satisfies ComponentMeta;
|