@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,184 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Toast — notifications that stack, push each other up, and expand on hover.
|
|
4
|
+
*
|
|
5
|
+
* One region per page. Anything on the page raises a toast by dispatching
|
|
6
|
+
* `ma:toast` on document (or calling toast() from @moonarc/core/toast).
|
|
7
|
+
* Enter is @starting-style, exit is a data attribute plus removal after the
|
|
8
|
+
* transition; the stack offset is one custom property per toast, read by
|
|
9
|
+
* CSS. The region is the live region; timers pause while it is hovered or
|
|
10
|
+
* focused, and while the tab is hidden.
|
|
11
|
+
*/
|
|
12
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
13
|
+
|
|
14
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
15
|
+
/** Corner. */
|
|
16
|
+
position?: 'bottom-right' | 'bottom-left' | 'bottom-center' | 'top-right' | 'top-left' | 'top-center';
|
|
17
|
+
/** Default lifetime in ms. */
|
|
18
|
+
duration?: number;
|
|
19
|
+
/** Toasts visible at once; older ones collapse behind. */
|
|
20
|
+
visible?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { position = 'bottom-right', duration = 4000, visible = 3, class: className, ...rest } = Astro.props;
|
|
24
|
+
const [side, align] = position.split('-') as ['top' | 'bottom', 'left' | 'right' | 'center'];
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<div class:list={['ma-toaster', className]} data-ma-toaster data-side={side} data-align={align} data-duration={duration} data-visible={visible} role="region" aria-label="Notifications" aria-live="polite" {...rest}>
|
|
28
|
+
<slot />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<style is:global>
|
|
32
|
+
@layer components {
|
|
33
|
+
:where(.ma-toaster) {
|
|
34
|
+
position: fixed;
|
|
35
|
+
z-index: 60;
|
|
36
|
+
display: grid;
|
|
37
|
+
gap: 0.5rem;
|
|
38
|
+
width: min(22rem, calc(100vw - 2rem));
|
|
39
|
+
margin: 1rem;
|
|
40
|
+
pointer-events: none;
|
|
41
|
+
}
|
|
42
|
+
:where(.ma-toaster[data-side='bottom']) {
|
|
43
|
+
bottom: 0;
|
|
44
|
+
align-content: end;
|
|
45
|
+
}
|
|
46
|
+
:where(.ma-toaster[data-side='top']) {
|
|
47
|
+
top: 0;
|
|
48
|
+
align-content: start;
|
|
49
|
+
}
|
|
50
|
+
:where(.ma-toaster[data-align='right']) {
|
|
51
|
+
right: 0;
|
|
52
|
+
}
|
|
53
|
+
:where(.ma-toaster[data-align='left']) {
|
|
54
|
+
left: 0;
|
|
55
|
+
}
|
|
56
|
+
:where(.ma-toaster[data-align='center']) {
|
|
57
|
+
left: 50%;
|
|
58
|
+
translate: -50% 0;
|
|
59
|
+
}
|
|
60
|
+
:where(.ma-toast) {
|
|
61
|
+
grid-area: 1 / 1;
|
|
62
|
+
pointer-events: auto;
|
|
63
|
+
border: 1px solid var(--ma-edge);
|
|
64
|
+
border-radius: 0.75rem;
|
|
65
|
+
padding: 0.75rem 1rem;
|
|
66
|
+
background: var(--ma-panel);
|
|
67
|
+
color: inherit;
|
|
68
|
+
box-shadow: 0 12px 32px -16px rgb(0 0 0 / 0.35);
|
|
69
|
+
font-size: 0.875rem;
|
|
70
|
+
line-height: 1.4;
|
|
71
|
+
/* --ma-i: 0 for the newest, counting back; written by the script */
|
|
72
|
+
transform-origin: 50% 100%;
|
|
73
|
+
translate: 0 calc(var(--ma-i, 0) * -0.75rem);
|
|
74
|
+
scale: calc(1 - var(--ma-i, 0) * 0.05);
|
|
75
|
+
opacity: calc(1 - var(--ma-i, 0) * 0.15);
|
|
76
|
+
transition:
|
|
77
|
+
translate var(--ma-duration) var(--ma-ease),
|
|
78
|
+
scale var(--ma-duration) var(--ma-ease),
|
|
79
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
80
|
+
}
|
|
81
|
+
:where(.ma-toaster[data-side='top'] .ma-toast) {
|
|
82
|
+
transform-origin: 50% 0;
|
|
83
|
+
translate: 0 calc(var(--ma-i, 0) * 0.75rem);
|
|
84
|
+
}
|
|
85
|
+
:where(.ma-toast:focus-visible) {
|
|
86
|
+
outline: 2px solid currentColor;
|
|
87
|
+
outline-offset: 2px;
|
|
88
|
+
}
|
|
89
|
+
:where(.ma-toast[data-hidden]) {
|
|
90
|
+
opacity: 0;
|
|
91
|
+
pointer-events: none;
|
|
92
|
+
}
|
|
93
|
+
/* hovered: the stack opens into a list */
|
|
94
|
+
:where(.ma-toaster:hover .ma-toast),
|
|
95
|
+
:where(.ma-toaster:focus-within .ma-toast) {
|
|
96
|
+
grid-area: auto;
|
|
97
|
+
translate: 0 0;
|
|
98
|
+
scale: 1;
|
|
99
|
+
opacity: 1;
|
|
100
|
+
}
|
|
101
|
+
@starting-style {
|
|
102
|
+
:where(.ma-toast) {
|
|
103
|
+
translate: 0 var(--ma-travel-enter, 24px);
|
|
104
|
+
opacity: 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
:where(.ma-toaster[data-side='top'] .ma-toast[data-leaving]),
|
|
108
|
+
:where(.ma-toast[data-leaving]) {
|
|
109
|
+
opacity: 0;
|
|
110
|
+
scale: 0.96;
|
|
111
|
+
transition-duration: var(--ma-duration-fast);
|
|
112
|
+
transition-timing-function: var(--ma-ease-in);
|
|
113
|
+
}
|
|
114
|
+
:where(.ma-toast__title) {
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
}
|
|
117
|
+
:where(.ma-toast__desc) {
|
|
118
|
+
opacity: 0.7;
|
|
119
|
+
margin-top: 0.125rem;
|
|
120
|
+
}
|
|
121
|
+
@media (prefers-reduced-motion: reduce) {
|
|
122
|
+
/* a fade in place: only opacity transitions, so the entrance never slides and the stack's offsets change at once */
|
|
123
|
+
:where(.ma-toast) {
|
|
124
|
+
transition: opacity var(--ma-duration-fast) linear;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</style>
|
|
129
|
+
|
|
130
|
+
<script>
|
|
131
|
+
import { onMount } from '../lib/runtime';
|
|
132
|
+
|
|
133
|
+
type Detail = { title: string; description?: string; duration?: number };
|
|
134
|
+
|
|
135
|
+
onMount<HTMLElement>('[data-ma-toaster]', (region, { signal }) => {
|
|
136
|
+
const life = Number(region.dataset.duration ?? 4000);
|
|
137
|
+
const visible = Number(region.dataset.visible ?? 3);
|
|
138
|
+
const fast = 200;
|
|
139
|
+
// the clock stops while a toast is being read (the pointer over the region, the focus in it) and while the tab is hidden
|
|
140
|
+
const held = () => document.hidden || region.matches(':hover, :focus-within');
|
|
141
|
+
|
|
142
|
+
const restack = () => {
|
|
143
|
+
const items = [...region.querySelectorAll<HTMLElement>('.ma-toast:not([data-leaving])')].reverse();
|
|
144
|
+
items.forEach((t, i) => {
|
|
145
|
+
t.style.setProperty('--ma-i', String(i));
|
|
146
|
+
if (i >= visible) t.dataset.hidden = '';
|
|
147
|
+
else delete t.dataset.hidden;
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
const dismiss = (t: HTMLElement) => {
|
|
151
|
+
t.dataset.leaving = '';
|
|
152
|
+
restack();
|
|
153
|
+
setTimeout(() => t.remove(), fast);
|
|
154
|
+
};
|
|
155
|
+
const show = ({ title, description, duration }: Detail) => {
|
|
156
|
+
const t = document.createElement('div');
|
|
157
|
+
t.className = 'ma-toast';
|
|
158
|
+
// no role="status" of its own: a live region inserted with its text already in it is often not read, so the region,
|
|
159
|
+
// live since the page loaded, is the one announcing. Tab reaches the toast, and the focus holds it like the pointer.
|
|
160
|
+
t.tabIndex = 0;
|
|
161
|
+
t.innerHTML = `<div class="ma-toast__title"></div>${description ? '<div class="ma-toast__desc"></div>' : ''}`;
|
|
162
|
+
t.querySelector('.ma-toast__title')!.textContent = title;
|
|
163
|
+
if (description) t.querySelector('.ma-toast__desc')!.textContent = description;
|
|
164
|
+
region.append(t);
|
|
165
|
+
restack();
|
|
166
|
+
let left = duration ?? life;
|
|
167
|
+
let last = performance.now();
|
|
168
|
+
const tick = () => {
|
|
169
|
+
if (!t.isConnected) return;
|
|
170
|
+
const now = performance.now();
|
|
171
|
+
// one tick counts 250 ms at most: a longer gap is time the page did not run (timers stopped in a hidden tab, as
|
|
172
|
+
// WebKit on iOS does, or throttled to one a minute), and counting it would drop the toast the moment you are back
|
|
173
|
+
if (!held()) left -= Math.min(now - last, 250);
|
|
174
|
+
last = now;
|
|
175
|
+
if (left <= 0) dismiss(t);
|
|
176
|
+
else setTimeout(tick, 100);
|
|
177
|
+
};
|
|
178
|
+
setTimeout(tick, 100);
|
|
179
|
+
t.addEventListener('click', () => dismiss(t), { signal });
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
document.addEventListener('ma:toast', ((e: CustomEvent<Detail>) => show(e.detail)) as EventListener, { signal });
|
|
183
|
+
});
|
|
184
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Toast',
|
|
5
|
+
slug: 'toast',
|
|
6
|
+
title: 'Toast',
|
|
7
|
+
description: 'Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover. Raise one from anywhere with toast() or a DOM event; timers pause while the stack is hovered or focused and while the tab is hidden, and the region is a polite live region. The stack geometry is CSS reading one custom property per toast.',
|
|
8
|
+
summary: 'Toast notifications that slide in, stack, and expand into a list on hover, raised from anywhere with toast().',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'C',
|
|
11
|
+
trigger: ['click', 'hover'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['starting-style'], fallback: 'toasts appear without the entrance' },
|
|
13
|
+
readout: "toast({ title: 'Saved' })",
|
|
14
|
+
usage: `---\n// once, in the layout\n---\n<Toast position="bottom-right" duration={4000} />\n\n<script>\n import { toast } from '@moonarc/core/toast';\n document.querySelector('#save')?.addEventListener('click', () => toast({ title: 'Saved', description: 'Just now.' }));\n</script>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'position', type: "'bottom-right' | 'bottom-left' | 'bottom-center' | 'top-right' | 'top-left' | 'top-center'", default: "'bottom-right'", description: 'Corner.' },
|
|
17
|
+
{ name: 'duration', type: 'number', default: '4000', description: 'Default lifetime in ms; a toast can override it.' },
|
|
18
|
+
{ name: 'visible', type: 'number', default: '3', description: 'Toasts visible at once; older ones collapse behind, and all show on hover.' },
|
|
19
|
+
],
|
|
20
|
+
reducedMotion: 'Toasts fade in and out in place: only opacity transitions, so nothing slides, and the stack\'s offsets change at once.',
|
|
21
|
+
clientRouter: 'The region is bound through the shared runtime; toasts belong to the page they were raised on.',
|
|
22
|
+
craft: [
|
|
23
|
+
'Stacked toasts share one grid cell; each is offset and scaled by its index, so the stack is three CSS expressions and no measuring.',
|
|
24
|
+
'On hover the grid-area returns to auto and the stack becomes a list: the same elements, a different layout, one transition.',
|
|
25
|
+
'The clock stops while the pointer is over the region, while the focus is in it (Tab reaches every toast) and while the tab is hidden, so nothing disappears while you read it or while you are away.',
|
|
26
|
+
'The region is the live region (aria-live="polite"), on the page from the first paint; a toast adds its text to it. A new element with role="status" and its text already inside is often not announced at all.',
|
|
27
|
+
'Exit is a data attribute for 200 ms, then removal: the element leaves the DOM only after the eye has seen it go.',
|
|
28
|
+
],
|
|
29
|
+
related: ['dialog', 'drawer', 'popover'],
|
|
30
|
+
intents: ['toast notification', 'sonner alternative', 'stacked toasts', 'notification stack', 'toast astro'],
|
|
31
|
+
replaces: ['Sonner', 'react-hot-toast', 'Toast (shadcn)'],
|
|
32
|
+
fixture: { slot: '<div class="ma-toast">Fixture content for Toast</div>', expect: 'Fixture content for Toast' },
|
|
33
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TracingBeam — a line beside an article fills as it is read, a dot at its
|
|
4
|
+
* head. Zero JS: one registered percentage animates on the article's own
|
|
5
|
+
* view() timeline over its `contain` range, and both the fill's height and
|
|
6
|
+
* the head's position read that one value. Without scroll timelines, and
|
|
7
|
+
* under reduced motion, the line is full and still. Correct for an article
|
|
8
|
+
* shorter than the viewport too: the range then runs from fully visible at
|
|
9
|
+
* the bottom to fully visible at the top.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
12
|
+
|
|
13
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
14
|
+
as?: HTMLTag;
|
|
15
|
+
/** Beam colour. */
|
|
16
|
+
color?: string;
|
|
17
|
+
/** Gutter the line lives in, any CSS length. */
|
|
18
|
+
offset?: string;
|
|
19
|
+
/** Line width in px. */
|
|
20
|
+
width?: number;
|
|
21
|
+
/** animation-range on the article's view timeline. */
|
|
22
|
+
range?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { as: Tag = 'div', color = 'var(--ma-ink)', offset = '2.5rem', width = 2, range = 'contain 0% contain 100%', class: className, style, ...rest } = Astro.props;
|
|
26
|
+
const vars = [`--ma-trace-c:${color}`, `--ma-trace-gutter:${offset}`, `--ma-trace-w:${width}px`, `--ma-trace-range:${range}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<Tag class:list={['ma-trace', className]} style={vars} {...rest}>
|
|
30
|
+
<div class="ma-trace__rail" aria-hidden="true">
|
|
31
|
+
<span class="ma-trace__line"></span>
|
|
32
|
+
<span class="ma-trace__fill"></span>
|
|
33
|
+
<span class="ma-trace__head"></span>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="ma-trace__body"><slot /></div>
|
|
36
|
+
</Tag>
|
|
37
|
+
|
|
38
|
+
<style is:global>
|
|
39
|
+
@property --ma-trace-p {
|
|
40
|
+
syntax: '<percentage>';
|
|
41
|
+
inherits: true;
|
|
42
|
+
initial-value: 100%;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@layer components {
|
|
46
|
+
:where(.ma-trace) {
|
|
47
|
+
position: relative;
|
|
48
|
+
display: grid;
|
|
49
|
+
grid-template-columns: var(--ma-trace-gutter, 2.5rem) minmax(0, 1fr);
|
|
50
|
+
}
|
|
51
|
+
:where(.ma-trace__rail) {
|
|
52
|
+
position: relative;
|
|
53
|
+
grid-column: 1;
|
|
54
|
+
grid-row: 1;
|
|
55
|
+
}
|
|
56
|
+
:where(.ma-trace__body) {
|
|
57
|
+
grid-column: 2;
|
|
58
|
+
grid-row: 1;
|
|
59
|
+
min-width: 0;
|
|
60
|
+
}
|
|
61
|
+
:where(.ma-trace__line),
|
|
62
|
+
:where(.ma-trace__fill) {
|
|
63
|
+
position: absolute;
|
|
64
|
+
top: 0;
|
|
65
|
+
left: calc(50% - var(--ma-trace-w, 2px) / 2);
|
|
66
|
+
width: var(--ma-trace-w, 2px);
|
|
67
|
+
border-radius: 999px;
|
|
68
|
+
}
|
|
69
|
+
:where(.ma-trace__line) {
|
|
70
|
+
height: 100%;
|
|
71
|
+
background: var(--ma-edge);
|
|
72
|
+
}
|
|
73
|
+
:where(.ma-trace__fill) {
|
|
74
|
+
height: var(--ma-trace-p, 100%);
|
|
75
|
+
background: linear-gradient(to bottom, color-mix(in srgb, var(--ma-trace-c, var(--ma-ink)) 35%, transparent), var(--ma-trace-c, var(--ma-ink)));
|
|
76
|
+
}
|
|
77
|
+
:where(.ma-trace__head) {
|
|
78
|
+
position: absolute;
|
|
79
|
+
top: var(--ma-trace-p, 100%);
|
|
80
|
+
left: 50%;
|
|
81
|
+
width: calc(var(--ma-trace-w, 2px) * 5);
|
|
82
|
+
height: calc(var(--ma-trace-w, 2px) * 5);
|
|
83
|
+
translate: -50% -50%;
|
|
84
|
+
border-radius: 50%;
|
|
85
|
+
background: var(--ma-trace-c, var(--ma-ink));
|
|
86
|
+
box-shadow: 0 0 0 calc(var(--ma-trace-w, 2px) * 2) color-mix(in srgb, var(--ma-trace-c, var(--ma-ink)) 22%, transparent);
|
|
87
|
+
}
|
|
88
|
+
@supports (animation-timeline: view()) {
|
|
89
|
+
:where(.ma-trace) {
|
|
90
|
+
animation: ma-trace linear both;
|
|
91
|
+
animation-range: var(--ma-trace-range, contain 0% contain 100%);
|
|
92
|
+
}
|
|
93
|
+
/* the timeline in its own rule, so a minifier cannot fold it into the shorthand */
|
|
94
|
+
:where(html) :where(.ma-trace) {
|
|
95
|
+
animation-timeline: view();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
@media (prefers-reduced-motion: reduce) {
|
|
99
|
+
:where(.ma-trace) {
|
|
100
|
+
animation: none;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@keyframes ma-trace {
|
|
106
|
+
from {
|
|
107
|
+
--ma-trace-p: 0%;
|
|
108
|
+
}
|
|
109
|
+
to {
|
|
110
|
+
--ma-trace-p: 100%;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'TracingBeam',
|
|
5
|
+
slug: 'tracing-beam',
|
|
6
|
+
title: 'Tracing Beam',
|
|
7
|
+
description: 'A line beside an article fills as it is read, with a dot at its head. One registered percentage animates on the article\'s own view() timeline, and both the fill\'s height and the head\'s position read that value. Without scroll timelines the line is full and still. Correct for an article shorter than the viewport too. Zero JavaScript.',
|
|
8
|
+
summary: 'A tracing beam beside an article: a line that fills as you read, with a dot at its head.',
|
|
9
|
+
category: 'scroll',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['scroll'],
|
|
12
|
+
support: { baseline: 'limited', needs: ['scroll-timeline', 'property'], fallback: 'a full, static line with the dot at its end (Firefox)' },
|
|
13
|
+
readout: '<TracingBeam color="var(--action)">',
|
|
14
|
+
usage: `<TracingBeam color="var(--ma-ink)" offset="2.5rem">\n <article class="prose">\n <h1>…</h1>\n </article>\n</TracingBeam>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'as', type: 'HTMLTag', default: "'div'", description: 'Element to render.' },
|
|
17
|
+
{ name: 'color', type: 'string', default: 'var(--ma-ink)', description: 'Beam colour.' },
|
|
18
|
+
{ name: 'offset', type: 'string', default: "'2.5rem'", description: 'Width of the gutter the line lives in.' },
|
|
19
|
+
{ name: 'width', type: 'number', default: '2', description: 'Line width in px; the head is five times it.' },
|
|
20
|
+
{ name: 'range', type: 'string', default: "'contain 0% contain 100%'", description: 'animation-range on the article\'s view timeline.' },
|
|
21
|
+
],
|
|
22
|
+
reducedMotion: 'A full line, the dot at its end; nothing tracks the scroll.',
|
|
23
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
24
|
+
craft: [
|
|
25
|
+
'One animated value, two readers: the fill\'s height and the head\'s top are both var(--ma-trace-p), so they cannot fall out of step and the head never needs the rail\'s height.',
|
|
26
|
+
'The contain range means 0 % when the article\'s top reaches the top of the viewport and 100 % when its bottom reaches the bottom. The line is the part of the article you have scrolled past. For a short article the same range runs from fully visible at the bottom to fully visible at the top.',
|
|
27
|
+
'The fill is a gradient from 35 % to full colour, so the beam reads as light travelling down, not a progress bar.',
|
|
28
|
+
'The property inherits and its initial value is 100 %, so a browser without scroll timelines shows a finished line, never an empty one.',
|
|
29
|
+
],
|
|
30
|
+
related: ['scroll-progress', 'reading-toc', 'reveal'],
|
|
31
|
+
intents: ['tracing beam', 'article progress line', 'scroll progress beside content', 'vertical reading progress', 'beam follows scroll'],
|
|
32
|
+
replaces: ['TracingBeam (Aceternity)', 'scroll progress line (Motion Primitives)'],
|
|
33
|
+
fixture: { slot: 'Fixture content for TracingBeam', expect: 'Fixture content for TracingBeam' },
|
|
34
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Typewriter — per-character typing in any font, zero JS.
|
|
4
|
+
*
|
|
5
|
+
* Each character is a span whose `display` animates from none to inline on
|
|
6
|
+
* its own delay, so the caret at the end of the text sits after the last
|
|
7
|
+
* visible character without measuring anything. Browsers that cannot animate
|
|
8
|
+
* `display` still type through the `font-size` beside it (below); reduced
|
|
9
|
+
* motion shows the text immediately.
|
|
10
|
+
*
|
|
11
|
+
* The `font-size` beside `display` in the keyframes, and the `linear` timing,
|
|
12
|
+
* are what make the reveal arrive: a *stepped* keyframe that lands more than a
|
|
13
|
+
* second after the document starts never reaches layout in WebKit 26.6 — the
|
|
14
|
+
* character keeps a computed `display: inline` and is never given a box. An
|
|
15
|
+
* interpolated one does. Measured in all three engines by
|
|
16
|
+
* `tests/site/_probe-typewriter.mjs`; the record is docs/typewriter-webkit.md.
|
|
17
|
+
*/
|
|
18
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
21
|
+
/** The text to type. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** Element to render. */
|
|
24
|
+
as?: HTMLTag;
|
|
25
|
+
/** Milliseconds per character. */
|
|
26
|
+
speed?: number;
|
|
27
|
+
/** Delay before the first character, ms. */
|
|
28
|
+
delay?: number;
|
|
29
|
+
/** Show the caret. */
|
|
30
|
+
cursor?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { text, as: Tag = 'span', speed = 55, delay = 0, cursor = true, class: className, style, ...rest } = Astro.props;
|
|
34
|
+
|
|
35
|
+
// Words are inline-block so lines break between words, not inside one (only a
|
|
36
|
+
// word longer than the line breaks); the trailing space belongs to the word so
|
|
37
|
+
// it types as one keystroke. A character is a grapheme, not a code point: an
|
|
38
|
+
// emoji with a skin tone or a letter with a combining accent types as one.
|
|
39
|
+
const words: string[] = text.split(/(?<=\s)/);
|
|
40
|
+
const graphemes = (s: string) => Array.from(new Intl.Segmenter().segment(s), (g) => g.segment);
|
|
41
|
+
let index = 0;
|
|
42
|
+
const total = graphemes(text).length;
|
|
43
|
+
const vars = [`--ma-type-n:${total}`, `--ma-speed:${speed}ms`, delay ? `--ma-type-delay:${delay}ms` : '', typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
44
|
+
// dir="auto" below: the words are inline-blocks, which bidi orders by the page's direction, so Latin text on a
|
|
45
|
+
// right-to-left page typed its words back to front. The element takes its direction from its own text instead.
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<Tag
|
|
49
|
+
class:list={['ma-type', className]}
|
|
50
|
+
data-cursor={cursor ? '' : undefined}
|
|
51
|
+
dir="auto"
|
|
52
|
+
style={vars}
|
|
53
|
+
{...rest}
|
|
54
|
+
>
|
|
55
|
+
{
|
|
56
|
+
words.map((word) => (
|
|
57
|
+
<span class="ma-type__word" aria-hidden="true">
|
|
58
|
+
{graphemes(word).map((ch) => (
|
|
59
|
+
<span class="ma-type__char" style={`--ma-i:${index++}`}>
|
|
60
|
+
{ch}
|
|
61
|
+
</span>
|
|
62
|
+
))}
|
|
63
|
+
</span>
|
|
64
|
+
))
|
|
65
|
+
}
|
|
66
|
+
<span class="ma-sr">{text}</span>
|
|
67
|
+
</Tag>
|
|
68
|
+
|
|
69
|
+
<style is:global>
|
|
70
|
+
@layer components {
|
|
71
|
+
/* a word is an inline-block as wide as its text, so it moves to the next line whole; only a word longer than the
|
|
72
|
+
line itself (a URL, a package path on a phone) breaks inside, where nowrap used to run it out of its box */
|
|
73
|
+
:where(.ma-type__word) {
|
|
74
|
+
display: inline-block;
|
|
75
|
+
overflow-wrap: anywhere;
|
|
76
|
+
}
|
|
77
|
+
:where(.ma-type__char) {
|
|
78
|
+
white-space: pre;
|
|
79
|
+
animation: ma-type-char 1ms linear both;
|
|
80
|
+
animation-delay: calc(var(--ma-type-delay, 0ms) + var(--ma-i) * var(--ma-speed, 55ms));
|
|
81
|
+
}
|
|
82
|
+
/* the caret rides on the last word, not after it: a word is an inline-block, and a caret beside one could wrap to a
|
|
83
|
+
line of its own on a narrow screen; inside the word's nowrap box it stays with the last character */
|
|
84
|
+
:where(.ma-type[data-cursor] > .ma-type__word:nth-last-child(2))::after {
|
|
85
|
+
content: '';
|
|
86
|
+
display: inline-block;
|
|
87
|
+
width: 0.08em;
|
|
88
|
+
min-width: 1.5px;
|
|
89
|
+
height: 1em;
|
|
90
|
+
margin-inline-start: 0.08em;
|
|
91
|
+
vertical-align: -0.12em;
|
|
92
|
+
background: currentColor;
|
|
93
|
+
/* solid while typing, blinking once idle */
|
|
94
|
+
animation: ma-type-blink 1s step-end infinite;
|
|
95
|
+
animation-delay: calc(var(--ma-type-delay, 0ms) + var(--ma-type-n) * var(--ma-speed, 55ms));
|
|
96
|
+
}
|
|
97
|
+
@media (prefers-reduced-motion: reduce) {
|
|
98
|
+
:where(.ma-type__char) {
|
|
99
|
+
animation: none;
|
|
100
|
+
}
|
|
101
|
+
:where(.ma-type[data-cursor] > .ma-type__word:nth-last-child(2))::after {
|
|
102
|
+
animation: none;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* font-size rides along so the reveal is interpolated, not stepped: a stepped one is
|
|
108
|
+
dropped after the document's first second in WebKit (see the component's comment) */
|
|
109
|
+
@keyframes ma-type-char {
|
|
110
|
+
from {
|
|
111
|
+
display: none;
|
|
112
|
+
font-size: 0;
|
|
113
|
+
}
|
|
114
|
+
to {
|
|
115
|
+
display: inline;
|
|
116
|
+
font-size: 1em;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
@keyframes ma-type-blink {
|
|
120
|
+
50% {
|
|
121
|
+
opacity: 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Typewriter',
|
|
5
|
+
slug: 'typewriter',
|
|
6
|
+
title: 'Typewriter',
|
|
7
|
+
description:
|
|
8
|
+
'Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle. Pure CSS: every character is a span whose display animates on its own delay.',
|
|
9
|
+
summary: 'A typewriter text effect: characters appear one at a time, with a caret that blinks once typing stops.',
|
|
10
|
+
category: 'text',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['load'],
|
|
13
|
+
support: { baseline: 'limited', needs: ['display-animation'], fallback: 'every character still types in turn, held at zero size by the font-size in the same keyframes until then (Firefox)' },
|
|
14
|
+
readout: '<Typewriter speed={45}>',
|
|
15
|
+
usage: `<Typewriter text="npx astro add moonarc" speed={55} />`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'text', type: 'string', description: 'The text to type.' },
|
|
18
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
19
|
+
{ name: 'speed', type: 'number', default: '55', description: 'Milliseconds per character. 40–70 reads as typing; below 30 reads as printing.' },
|
|
20
|
+
{ name: 'delay', type: 'number', default: '0', description: 'Delay before the first character, ms.' },
|
|
21
|
+
{ name: 'cursor', type: 'boolean', default: 'true', description: 'Show the caret.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'The full text is shown immediately and the caret does not blink.',
|
|
24
|
+
clientRouter: 'CSS-only; every navigation renders fresh elements whose animations start on their own.',
|
|
25
|
+
craft: [
|
|
26
|
+
'Per-character display animation means proportional fonts type correctly; width-based tricks only work in monospace.',
|
|
27
|
+
'The reveal is timed linear so the engine interpolates it. A stepped keyframe landing more than 1 000 ms into a page reaches computed style but never layout in WebKit 26.6, and the character keeps display: inline with no box.',
|
|
28
|
+
'Words are inline-block so a line breaks between words while typing; only a word longer than the line itself (a URL on a phone) breaks inside, where it would otherwise run out of its box.',
|
|
29
|
+
'The caret is solid during typing and blinks only once idle, like a real terminal.',
|
|
30
|
+
'A visually hidden span (.ma-sr) carries the whole string; the characters are aria-hidden.',
|
|
31
|
+
'Browsers that cannot animate display still type: the font-size in the same keyframes holds each character at zero size until its turn.',
|
|
32
|
+
'A character is a grapheme (Intl.Segmenter), so an emoji with a skin tone or a letter with a combining accent types as one keystroke. The element takes dir="auto", so Latin text on a right-to-left page still types left to right.',
|
|
33
|
+
],
|
|
34
|
+
related: ['rotating-text', 'split-text'],
|
|
35
|
+
intents: ['typewriter effect', 'typing animation', 'type text one character at a time', 'terminal typing effect astro'],
|
|
36
|
+
replaces: ['TextType (React Bits)', 'astro-typewriter', 'typed.js for single strings'],
|
|
37
|
+
// an emoji with a skin tone is one keystroke (a grapheme): 32 characters to type, where code points made it 33
|
|
38
|
+
fixture: { props: { text: 'Fixture content for Typewriter 👍🏽' }, expect: 'style="--ma-type-n:32;--ma-speed:55ms"' },
|
|
39
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* UnderlineHover — the line enters from the left and leaves to the right.
|
|
4
|
+
* One background gradient: its size grows on hover while anchored left,
|
|
5
|
+
* and shrinks on leave while anchored right, so the exit runs the same
|
|
6
|
+
* direction as the entrance. Works on the element or from the link or
|
|
7
|
+
* button around it. Zero JS.
|
|
8
|
+
*/
|
|
9
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
10
|
+
|
|
11
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
12
|
+
/** Element to render. */
|
|
13
|
+
as?: HTMLTag;
|
|
14
|
+
/** Line thickness, any CSS length. */
|
|
15
|
+
thickness?: string;
|
|
16
|
+
/** Gap between the text and the line. */
|
|
17
|
+
offset?: string;
|
|
18
|
+
/** Line colour. */
|
|
19
|
+
color?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { as: Tag = 'span', thickness = '0.08em', offset = '0.12em', color = 'currentColor', class: className, style, ...rest } = Astro.props;
|
|
23
|
+
const vars = [`--ma-underline-w:${thickness}`, `--ma-underline-offset:${offset}`, `--ma-underline-color:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<Tag class:list={['ma-underline', className]} style={vars} {...rest}><slot /></Tag>
|
|
27
|
+
|
|
28
|
+
<style is:global>
|
|
29
|
+
@layer components {
|
|
30
|
+
:where(.ma-underline) {
|
|
31
|
+
text-decoration: none;
|
|
32
|
+
padding-bottom: var(--ma-underline-offset, 0.12em);
|
|
33
|
+
background-image: linear-gradient(var(--ma-underline-color, currentColor), var(--ma-underline-color, currentColor));
|
|
34
|
+
background-repeat: no-repeat;
|
|
35
|
+
background-size: 0% var(--ma-underline-w, 0.08em);
|
|
36
|
+
background-position: 100% 100%;
|
|
37
|
+
-webkit-box-decoration-break: clone;
|
|
38
|
+
box-decoration-break: clone;
|
|
39
|
+
transition: background-size var(--ma-duration-fast) var(--ma-ease-out);
|
|
40
|
+
}
|
|
41
|
+
@media (hover: hover) {
|
|
42
|
+
:where(.ma-underline:hover),
|
|
43
|
+
:where(:is(a, button):hover .ma-underline) {
|
|
44
|
+
background-size: 100% var(--ma-underline-w, 0.08em);
|
|
45
|
+
background-position: 0% 100%;
|
|
46
|
+
transition-duration: var(--ma-duration);
|
|
47
|
+
transition-timing-function: var(--ma-ease);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
:where(.ma-underline:focus-visible),
|
|
51
|
+
:where(:is(a, button):focus-visible .ma-underline) {
|
|
52
|
+
background-size: 100% var(--ma-underline-w, 0.08em);
|
|
53
|
+
background-position: 0% 100%;
|
|
54
|
+
}
|
|
55
|
+
@media (prefers-reduced-motion: reduce) {
|
|
56
|
+
:where(.ma-underline) {
|
|
57
|
+
transition: none;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'UnderlineHover',
|
|
5
|
+
slug: 'underline-hover',
|
|
6
|
+
title: 'Underline Hover',
|
|
7
|
+
description:
|
|
8
|
+
'A link underline that enters from the left on hover and leaves to the right, so the exit continues the entrance instead of rewinding it. One background gradient, no pseudo-element, wraps across lines. Pure CSS; triggered by the link around it.',
|
|
9
|
+
summary: 'An animated link underline that enters from the left on hover and leaves to the right, across wrapped lines.',
|
|
10
|
+
category: 'text',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['hover'],
|
|
13
|
+
support: { baseline: 'widely', needs: [] },
|
|
14
|
+
readout: '<UnderlineHover thickness="0.08em">',
|
|
15
|
+
usage: `<a href="/docs/"><UnderlineHover>Read the docs</UnderlineHover></a>\n<UnderlineHover as="a" href="/pro/" thickness="2px" color="var(--ma-ink)">Pro</UnderlineHover>`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render. Use a or wrap it in one.' },
|
|
18
|
+
{ name: 'thickness', type: 'string', default: "'0.08em'", description: 'Line thickness.' },
|
|
19
|
+
{ name: 'offset', type: 'string', default: "'0.12em'", description: 'Gap between the text and the line.' },
|
|
20
|
+
{ name: 'color', type: 'string', default: "'currentColor'", description: 'Line colour.' },
|
|
21
|
+
],
|
|
22
|
+
reducedMotion: 'The line appears and disappears without sliding.',
|
|
23
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
24
|
+
craft: [
|
|
25
|
+
'Enter left, leave right: background-position flips instantly between the two states and only background-size transitions, so the line grows from the left and, on leave, shrinks toward the right: one direction of travel, like a pen that keeps going.',
|
|
26
|
+
'Entrance on the preset spring at the full duration, exit at the fast duration on ease-out: the acknowledgement lingers, the release gets out of the way.',
|
|
27
|
+
'A gradient on the element itself, not a pseudo-element, so box-decoration-break: clone paints the line on every wrapped line of a multi-line link.',
|
|
28
|
+
'Hover is read from the containing link or button, so the whole target counts; focus-visible draws the line for keyboard users.',
|
|
29
|
+
],
|
|
30
|
+
related: ['text-roll', 'bubble-text', 'highlight-marker'],
|
|
31
|
+
intents: ['underline hover animation', 'link underline slide in', 'animated underline css', 'underline left to right hover', 'nav link underline effect'],
|
|
32
|
+
replaces: ['underline hover effects (Animata, uiverse)', 'Framer Motion layout underlines'],
|
|
33
|
+
fixture: { expect: 'Fixture content for UnderlineHover' },
|
|
34
|
+
} satisfies ComponentMeta;
|