@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,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* VanishInput — placeholders that take turns, and text that blurs away on
|
|
4
|
+
* submit. A native <input> inside your form; the `placeholder` attribute
|
|
5
|
+
* carries the first suggestion (what a screen reader and a page without
|
|
6
|
+
* CSS get) and an aria-hidden overlay cycles through the rest: one
|
|
7
|
+
* registered number runs 0 → 1 over the whole cycle and each suggestion
|
|
8
|
+
* computes its own on/off from it (the AnimatedList technique), so the
|
|
9
|
+
* entrances and exits are transitions on the preset curve and no keyframe
|
|
10
|
+
* knows how many there are. The overlay hides on focus and once there is a
|
|
11
|
+
* value. The vanish is a small script listening for `submit` on
|
|
12
|
+
* `document`: when your handler has called preventDefault() (a fetch), the
|
|
13
|
+
* value is copied into one span per character, each blurs, rises and fades
|
|
14
|
+
* on the stagger token, the input is cleared, and the spans go once the
|
|
15
|
+
* animation ends. When nothing prevented the default the page is about to
|
|
16
|
+
* leave and the script does nothing.
|
|
17
|
+
*/
|
|
18
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
19
|
+
|
|
20
|
+
interface Props extends Omit<HTMLAttributes<'input'>, 'type' | 'placeholder'> {
|
|
21
|
+
/** Suggestions shown in turn; the first is the placeholder attribute. */
|
|
22
|
+
placeholders: string[];
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Text-like types only: the characters must be visible to vanish. */
|
|
25
|
+
type?: 'text' | 'email' | 'search' | 'url';
|
|
26
|
+
/** Visible label as <label for>. Without it, pass aria-label. */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** The input's id and the label's for. Unset, unique per render. */
|
|
29
|
+
id?: string;
|
|
30
|
+
/** Off by default, so the browser's suggestion list does not cover the hints; yours replaces it (one attribute either way). */
|
|
31
|
+
autocomplete?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { placeholders, name = 'q', type = 'text', label, id, autocomplete = 'off', class: className, style, ...rest } = Astro.props;
|
|
35
|
+
const list = placeholders.length ? placeholders : ['Type here'];
|
|
36
|
+
// unique per render, not per name: two waitlist forms on one page both name their field "email"
|
|
37
|
+
const inputId = id ?? `ma-vanish-${name}-${Math.random().toString(36).slice(2, 7)}`;
|
|
38
|
+
const vars = [`--ma-vanish-n:${list.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
{label && <label class="ma-vanish__label" for={inputId}>{label}</label>}
|
|
42
|
+
<span class:list={['ma-vanish', className]} style={vars} data-ma-vanish>
|
|
43
|
+
<input id={inputId} class="ma-vanish__input" type={type} name={name} placeholder={list[0]} autocomplete={autocomplete} {...rest} />
|
|
44
|
+
{list.length > 1 && (
|
|
45
|
+
<span class="ma-vanish__cycle" aria-hidden="true">
|
|
46
|
+
{list.map((p, i) => <span class="ma-vanish__hint" style={`--ma-vanish-i:${i}`}>{p}</span>)}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
<span class="ma-vanish__ghost" aria-hidden="true"></span>
|
|
50
|
+
</span>
|
|
51
|
+
|
|
52
|
+
<style is:global>
|
|
53
|
+
@property --ma-vanish-t {
|
|
54
|
+
syntax: '<number>';
|
|
55
|
+
inherits: true;
|
|
56
|
+
initial-value: 0;
|
|
57
|
+
}
|
|
58
|
+
@keyframes ma-vanish-cycle {
|
|
59
|
+
from {
|
|
60
|
+
--ma-vanish-t: 0;
|
|
61
|
+
}
|
|
62
|
+
to {
|
|
63
|
+
--ma-vanish-t: 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
@keyframes ma-vanish-out {
|
|
67
|
+
to {
|
|
68
|
+
opacity: 0;
|
|
69
|
+
translate: 0 calc(var(--ma-travel-enter) * -1);
|
|
70
|
+
filter: blur(6px);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
@layer components {
|
|
74
|
+
:where(.ma-vanish) {
|
|
75
|
+
/* one suggestion per slot; the whole cycle is n slots, on the ambient duration doubled */
|
|
76
|
+
--ma-vanish-slot: calc(var(--ma-dur-ambient) * 2);
|
|
77
|
+
position: relative;
|
|
78
|
+
display: inline-grid;
|
|
79
|
+
inline-size: 100%;
|
|
80
|
+
color: inherit;
|
|
81
|
+
}
|
|
82
|
+
:where(.ma-vanish__label) {
|
|
83
|
+
display: block;
|
|
84
|
+
margin-block-end: 0.5em;
|
|
85
|
+
font-size: 0.875em;
|
|
86
|
+
}
|
|
87
|
+
:where(.ma-vanish__input) {
|
|
88
|
+
grid-area: 1 / 1;
|
|
89
|
+
inline-size: 100%;
|
|
90
|
+
margin: 0;
|
|
91
|
+
padding: 0.7em 1em;
|
|
92
|
+
border: 1px solid var(--ma-edge);
|
|
93
|
+
/* a pill unless you say otherwise: set --ma-vanish-radius on the component or any ancestor */
|
|
94
|
+
border-radius: var(--ma-vanish-radius, 999px);
|
|
95
|
+
background: var(--ma-panel);
|
|
96
|
+
color: inherit;
|
|
97
|
+
font: inherit;
|
|
98
|
+
line-height: 1.4;
|
|
99
|
+
appearance: none;
|
|
100
|
+
-webkit-appearance: none;
|
|
101
|
+
transition: border-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
102
|
+
}
|
|
103
|
+
:where(.ma-vanish__input:focus-visible) {
|
|
104
|
+
outline: 2px solid var(--ma-ink);
|
|
105
|
+
outline-offset: 2px;
|
|
106
|
+
}
|
|
107
|
+
/* the vanish empties a required field in the submit dispatch itself, so a form that is sending or has sent (done, or
|
|
108
|
+
exists: already on the list) is not an error. The red is a hook: a caller on a coloured ground sets
|
|
109
|
+
--ma-vanish-invalid to a colour that keeps 3 : 1 there */
|
|
110
|
+
:where(.ma-vanish__input:user-invalid:not(form[data-pending] *, form[data-state='done'] *, form[data-state='exists'] *)) {
|
|
111
|
+
border-color: var(--ma-vanish-invalid, #e0563f);
|
|
112
|
+
}
|
|
113
|
+
:where(.ma-vanish__input:disabled) {
|
|
114
|
+
opacity: 0.5;
|
|
115
|
+
cursor: not-allowed;
|
|
116
|
+
}
|
|
117
|
+
/* secondary text at --ma-dim through the colour: opacity is what shows and hides the placeholder and the hints */
|
|
118
|
+
:where(.ma-vanish__input)::placeholder {
|
|
119
|
+
color: color-mix(in oklab, var(--ma-ink) calc(var(--ma-dim, 0.7) * 100%), transparent);
|
|
120
|
+
opacity: 1;
|
|
121
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
122
|
+
}
|
|
123
|
+
/* while the overlay cycles the native placeholder is invisible; on focus, or with a value, it is the other way round */
|
|
124
|
+
:where(.ma-vanish:has(.ma-vanish__cycle) .ma-vanish__input:not(:focus):placeholder-shown)::placeholder {
|
|
125
|
+
opacity: 0;
|
|
126
|
+
}
|
|
127
|
+
:where(.ma-vanish__cycle) {
|
|
128
|
+
grid-area: 1 / 1;
|
|
129
|
+
display: grid;
|
|
130
|
+
align-items: center;
|
|
131
|
+
padding: 0.7em 1em;
|
|
132
|
+
border: 1px solid transparent;
|
|
133
|
+
line-height: 1.4;
|
|
134
|
+
color: color-mix(in oklab, var(--ma-ink) calc(var(--ma-dim, 0.7) * 100%), transparent);
|
|
135
|
+
overflow: clip;
|
|
136
|
+
pointer-events: none;
|
|
137
|
+
animation: ma-vanish-cycle calc(var(--ma-vanish-n) * var(--ma-vanish-slot)) linear infinite;
|
|
138
|
+
}
|
|
139
|
+
:where(.ma-vanish__hint) {
|
|
140
|
+
/* the slot on now: k = floor(t × n); this one is on when k = i — a square, so no abs(): 1 − (k − i)² is 1 at k = i and ≤ 0 elsewhere */
|
|
141
|
+
--ma-vanish-k: round(down, var(--ma-vanish-t) * var(--ma-vanish-n));
|
|
142
|
+
--ma-vanish-on: clamp(0, 1 - (var(--ma-vanish-k) - var(--ma-vanish-i)) * (var(--ma-vanish-k) - var(--ma-vanish-i)), 1);
|
|
143
|
+
grid-area: 1 / 1;
|
|
144
|
+
white-space: nowrap;
|
|
145
|
+
overflow: clip;
|
|
146
|
+
text-overflow: ellipsis;
|
|
147
|
+
opacity: var(--ma-vanish-on);
|
|
148
|
+
translate: 0 calc((1 - var(--ma-vanish-on)) * var(--ma-travel-hover) * 2);
|
|
149
|
+
transition:
|
|
150
|
+
opacity var(--ma-duration) var(--ma-ease-out),
|
|
151
|
+
translate var(--ma-duration) var(--ma-ease);
|
|
152
|
+
}
|
|
153
|
+
:where(.ma-vanish:has(.ma-vanish__input:focus) .ma-vanish__cycle),
|
|
154
|
+
:where(.ma-vanish:has(.ma-vanish__input:not(:placeholder-shown)) .ma-vanish__cycle) {
|
|
155
|
+
opacity: 0;
|
|
156
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
157
|
+
}
|
|
158
|
+
/* the vanish: one span per character over the field, blurred away on the exit curve, staggered on the tight token */
|
|
159
|
+
:where(.ma-vanish__ghost) {
|
|
160
|
+
grid-area: 1 / 1;
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: center;
|
|
163
|
+
padding: 0.7em 1em;
|
|
164
|
+
border: 1px solid transparent;
|
|
165
|
+
line-height: 1.4;
|
|
166
|
+
white-space: pre;
|
|
167
|
+
overflow: clip;
|
|
168
|
+
pointer-events: none;
|
|
169
|
+
}
|
|
170
|
+
:where(.ma-vanish__ghost > i) {
|
|
171
|
+
font-style: normal;
|
|
172
|
+
animation: ma-vanish-out var(--ma-duration) var(--ma-ease-in) calc(var(--ma-vanish-i) * var(--ma-stagger-tight)) both;
|
|
173
|
+
}
|
|
174
|
+
/* while the characters leave, the field shows nothing of its own — no placeholder, no cycle — so the exit is the only thing moving */
|
|
175
|
+
:where(.ma-vanish[data-vanish] .ma-vanish__input)::placeholder,
|
|
176
|
+
:where(.ma-vanish[data-vanish] .ma-vanish__cycle) {
|
|
177
|
+
opacity: 0;
|
|
178
|
+
}
|
|
179
|
+
@media (forced-colors: active) {
|
|
180
|
+
:where(.ma-vanish__input) {
|
|
181
|
+
border-color: ButtonText;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
@media (prefers-reduced-motion: reduce) {
|
|
185
|
+
:where(.ma-vanish__cycle) {
|
|
186
|
+
animation: none;
|
|
187
|
+
display: none;
|
|
188
|
+
}
|
|
189
|
+
:where(.ma-vanish:has(.ma-vanish__cycle) .ma-vanish__input:not(:focus):placeholder-shown)::placeholder {
|
|
190
|
+
opacity: 1;
|
|
191
|
+
}
|
|
192
|
+
:where(.ma-vanish__ghost > i) {
|
|
193
|
+
animation-duration: var(--ma-duration-fast);
|
|
194
|
+
animation-delay: 0s;
|
|
195
|
+
filter: none;
|
|
196
|
+
translate: 0 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
</style>
|
|
201
|
+
|
|
202
|
+
<script>
|
|
203
|
+
import { onMount, prefersReducedMotion } from '../lib/runtime';
|
|
204
|
+
|
|
205
|
+
onMount<HTMLElement>('[data-ma-vanish]', (box, { signal }) => {
|
|
206
|
+
const input = box.querySelector('input')!;
|
|
207
|
+
const ghost = box.querySelector<HTMLElement>('.ma-vanish__ghost')!;
|
|
208
|
+
// on document, bubbling: it runs after every listener on the form itself, whatever the registration order, so defaultPrevented is final
|
|
209
|
+
document.addEventListener(
|
|
210
|
+
'submit',
|
|
211
|
+
(e) => {
|
|
212
|
+
if (e.target !== input.form || !e.defaultPrevented || !input.value) return;
|
|
213
|
+
if (prefersReducedMotion()) {
|
|
214
|
+
input.value = '';
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
ghost.replaceChildren(
|
|
218
|
+
...[...input.value].map((ch, i) => {
|
|
219
|
+
const s = document.createElement('i');
|
|
220
|
+
s.textContent = ch;
|
|
221
|
+
s.style.setProperty('--ma-vanish-i', String(i));
|
|
222
|
+
return s;
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
input.value = '';
|
|
226
|
+
box.setAttribute('data-vanish', '');
|
|
227
|
+
const last = ghost.lastElementChild!;
|
|
228
|
+
last.addEventListener(
|
|
229
|
+
'animationend',
|
|
230
|
+
() => {
|
|
231
|
+
ghost.replaceChildren();
|
|
232
|
+
box.removeAttribute('data-vanish');
|
|
233
|
+
},
|
|
234
|
+
{ once: true },
|
|
235
|
+
);
|
|
236
|
+
},
|
|
237
|
+
{ signal },
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'VanishInput',
|
|
5
|
+
slug: 'vanish-input',
|
|
6
|
+
title: 'Vanish Input',
|
|
7
|
+
description: 'Placeholders that take turns, and text that blurs away on submit. A native input in your form: the first suggestion is the placeholder attribute, the rest cycle in an aria-hidden overlay driven by one registered number, so their entrances are transitions on the preset curve. A small script listens for submit on the document and, only when your handler prevented the default, splits the value into characters that rise and blur out on the stagger token.',
|
|
8
|
+
summary: 'An input whose placeholders take turns and whose text blurs away on submit, inside your own form.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['click', 'always'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['property', 'has', 'css-math'], fallback: 'without @property or round() only the first placeholder shows; without :has() the native placeholder shows through the overlay' },
|
|
13
|
+
readout: '<VanishInput placeholders={hints} name="email">',
|
|
14
|
+
usage: `<form data-waitlist>\n <VanishInput type="email" name="email" label="Email" autocomplete="email" placeholders={['you@studio.example', 'Join 2,398 on the list', 'No spam, one mail at launch']} required />\n <button type="submit">Join</button>\n</form>\n\n<script>\n // your handler prevents the navigation: that is what starts the vanish\n document.querySelector('[data-waitlist]').addEventListener('submit', (e) => {\n e.preventDefault();\n fetch('/api/waitlist', { method: 'POST', body: new FormData(e.currentTarget) });\n });\n</script>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'placeholders', type: 'string[]', description: 'Suggestions shown in turn. The first is the placeholder attribute, the one a screen reader announces and a page without CSS shows.' },
|
|
17
|
+
{ name: 'name', type: 'string', default: "'q'", description: 'Form field name.' },
|
|
18
|
+
{ name: 'type', type: "'text' | 'email' | 'search' | 'url'", default: "'text'", description: 'Text-like types only: the characters must be visible to vanish, so password is not one of them.' },
|
|
19
|
+
{ name: 'label', type: 'string', description: 'Visible label as <label for>. Without it, pass aria-label.' },
|
|
20
|
+
{ name: 'id', type: 'string', default: 'unique per render', description: 'The input\'s id, and the label\'s for. Unset, ma-vanish- with the name and a random suffix, so two forms on one page with a field of the same name never share an id. Yours wins.' },
|
|
21
|
+
{ name: 'autocomplete', type: 'string', default: "'off'", description: 'Off by default, so the browser\'s suggestion list does not cover the cycling hints. Yours replaces it (the input carries one autocomplete attribute either way), so a waitlist field passes autocomplete="email".' },
|
|
22
|
+
{ name: '--ma-vanish-radius', type: 'CSS length (custom property)', default: '999px', description: 'The field\'s corner radius, a pill by default. Set it in a rule or style on the component or any ancestor, e.g. var(--ma-radius-md, 0.5rem) to follow a theme\'s radius.' },
|
|
23
|
+
{ name: '--ma-vanish-invalid', type: 'CSS colour (custom property)', default: '#e0563f', description: 'The border of a field the reader left invalid (:user-invalid). Set it in a rule or style on the component or any ancestor that knows its ground: a red that keeps 3 : 1 against it (WCAG 1.4.11), e.g. the theme\'s --destructive mixed with the ground\'s text on a coloured panel, where the fixed red falls to 2 : 1.' },
|
|
24
|
+
],
|
|
25
|
+
reducedMotion: 'No cycle (the first placeholder stands), and on submit the field simply clears.',
|
|
26
|
+
clientRouter: 'Bound through the shared runtime; the document listener is removed before a swap and set again after it.',
|
|
27
|
+
craft: [
|
|
28
|
+
'The placeholder attribute is real and carries the first suggestion: assistive tech reads it, a page without CSS shows it, and :placeholder-shown keeps working. The cycling copies are an overlay a screen reader never meets.',
|
|
29
|
+
'The cycle is one registered number 0 → 1 over n slots; each suggestion computes k = round(down, t × n) and is on when k equals its index, through a clamped square so abs() is not needed. Because on/off is a computed value, opacity and translate are transitions on the preset curve, and the keyframes are the same for three suggestions or ten.',
|
|
30
|
+
'The submit listener is on the document, in the bubbling phase, on purpose: listeners on the form run in registration order, so a listener there could run before your handler and read defaultPrevented too early. On the document it always runs after everything on the form. It never calls preventDefault() itself: a form without a handler navigates as it should.',
|
|
31
|
+
'The vanish is CSS: one span per character, each with its index as a custom property, blur + rise + fade on the exit curve, delayed by index on the tight stagger token. The script only builds the spans, clears the field and removes them on animationend.',
|
|
32
|
+
'A handler that prevents the default after an await is too late for the animation (the event has finished dispatching). This is stated here rather than worked around.',
|
|
33
|
+
'The hints and the placeholder are secondary text at --ma-dim, like every other secondary text in the library, and the dimming is in the colour (the ink mixed toward transparent in oklab) and not in opacity, because opacity is what shows and hides them.',
|
|
34
|
+
'The error border is :user-invalid, so it never shows before the reader has typed or submitted, and not while the field\'s form carries data-pending, data-state="done" or data-state="exists": the vanish empties a required field inside the submit dispatch, and an address that is being sent, has been, or was already on the list, is not an error. A field outside a form, or in a form without those attributes, keeps the red border as usual. The red is --ma-vanish-invalid when set: a fixed hue cannot keep 3 : 1 on every ground, so a caller on a coloured panel gives its own.',
|
|
35
|
+
],
|
|
36
|
+
related: ['prompt-input', 'typewriter', 'stateful-button'],
|
|
37
|
+
intents: ['placeholders and vanish effect', 'input with rotating placeholder', 'text vanishes on submit', 'animated placeholder input', 'search input hints cycle', 'waitlist input animation'],
|
|
38
|
+
replaces: ['PlaceholdersAndVanishInput (Aceternity)', 'VanishInput (Cult UI)'],
|
|
39
|
+
// the waitlist field (7G step 0b): a visible label, the caller's autocomplete — one attribute, email — and an id per render
|
|
40
|
+
fixture: { props: { name: 'email', type: 'email', label: 'Email', autocomplete: 'email', placeholders: ['Fixture content for VanishInput', 'Second hint'] }, expect: 'Fixture content for VanishInput' },
|
|
41
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* VideoDialog — a poster with a play button that opens the video in a
|
|
4
|
+
* dialog. The trigger is an invoker, so the dialog opens without any
|
|
5
|
+
* script; `morph` grows it out of the poster (Dialog morph). The script is
|
|
6
|
+
* what a dialog cannot do on its own: a file plays when it opens and stops
|
|
7
|
+
* when it closes; an embed's src is set when the dialog opens and blanked
|
|
8
|
+
* when it closes, so an iframe never loads behind a closed dialog and never
|
|
9
|
+
* keeps talking after it. Measured in phase 7D: a closed dialog does not
|
|
10
|
+
* stop sound, in any engine. `tracks` are the file's captions; without
|
|
11
|
+
* JavaScript an embed's frame holds a link to the player instead.
|
|
12
|
+
*/
|
|
13
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
14
|
+
import Dialog from './Dialog.astro';
|
|
15
|
+
|
|
16
|
+
interface Track {
|
|
17
|
+
src: string;
|
|
18
|
+
/** Language of the track (BCP 47), e.g. 'en'. */
|
|
19
|
+
srclang: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
kind?: 'captions' | 'subtitles' | 'descriptions' | 'chapters';
|
|
22
|
+
default?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
26
|
+
/** Dialog id; unique per page. */
|
|
27
|
+
id: string;
|
|
28
|
+
/** A video file (several formats as an array: webm, mp4), or with `embed` the URL of a player page (YouTube, Vimeo, …). */
|
|
29
|
+
src: string | string[];
|
|
30
|
+
poster: string;
|
|
31
|
+
/** src is an iframe URL. */
|
|
32
|
+
embed?: boolean;
|
|
33
|
+
/** Grow out of the poster and back into it on a view transition. */
|
|
34
|
+
morph?: boolean;
|
|
35
|
+
/** Dialog title, for the embed's name and the dialog's. */
|
|
36
|
+
title?: string;
|
|
37
|
+
/** Accessible name of the play button. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** CSS aspect ratio of the poster and the player. */
|
|
40
|
+
ratio?: string;
|
|
41
|
+
/** Text tracks for a file (WebVTT): captions, subtitles, descriptions. */
|
|
42
|
+
tracks?: Track[];
|
|
43
|
+
/** Add the inline click handler for browsers without invoker commands. */
|
|
44
|
+
polyfill?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { id, src, poster, embed = false, morph = false, title = 'Video', label = 'Play video', ratio = '16 / 9', tracks = [], polyfill = false, class: className, style, ...rest } = Astro.props;
|
|
48
|
+
const sources = Array.isArray(src) ? src : [src];
|
|
49
|
+
const type = (u: string) => (/\.webm(\?|$)/.test(u) ? 'video/webm' : /\.mp4(\?|$)/.test(u) ? 'video/mp4' : undefined);
|
|
50
|
+
const inline = [`--ma-vdialog-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
51
|
+
const triggerAttrs = { command: 'show-modal', commandfor: id };
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<div class:list={['ma-vdialog', className]} data-ma-vdialog data-embed={embed ? '' : undefined} style={inline} {...rest}>
|
|
55
|
+
<button type="button" class="ma-vdialog__thumb" aria-label={label} {...triggerAttrs}>
|
|
56
|
+
<img class="ma-vdialog__poster" src={poster} alt="" draggable="false" />
|
|
57
|
+
<span class="ma-vdialog__play" aria-hidden="true">
|
|
58
|
+
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M8 5.5v13l11-6.5z" fill="currentColor" /></svg>
|
|
59
|
+
</span>
|
|
60
|
+
</button>
|
|
61
|
+
<Dialog id={id} morph={morph} polyfill={polyfill} class="ma-vdialog__dialog" aria-label={title}>
|
|
62
|
+
<Fragment slot="trigger" />
|
|
63
|
+
<div class="ma-vdialog__frame">
|
|
64
|
+
{embed ? (
|
|
65
|
+
<>
|
|
66
|
+
<iframe class="ma-vdialog__media" data-src={sources[0]} title={title} allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
|
67
|
+
<noscript><a class="ma-vdialog__link" href={sources[0]}>{title}</a></noscript>
|
|
68
|
+
</>
|
|
69
|
+
) : (
|
|
70
|
+
<video class="ma-vdialog__media" poster={poster} controls playsinline preload="none">
|
|
71
|
+
{sources.map((u) => <source src={u} type={type(u)} />)}
|
|
72
|
+
{tracks.map((t) => <track src={t.src} srclang={t.srclang} label={t.label} kind={t.kind ?? 'captions'} default={t.default} />)}
|
|
73
|
+
</video>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</Dialog>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<style is:global>
|
|
80
|
+
@layer components {
|
|
81
|
+
:where(.ma-vdialog__thumb) {
|
|
82
|
+
position: relative;
|
|
83
|
+
display: block;
|
|
84
|
+
width: 100%;
|
|
85
|
+
aspect-ratio: var(--ma-vdialog-ratio, 16 / 9);
|
|
86
|
+
padding: 0;
|
|
87
|
+
border: 1px solid var(--ma-edge);
|
|
88
|
+
border-radius: 1rem;
|
|
89
|
+
overflow: clip;
|
|
90
|
+
background: var(--ma-ink);
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
isolation: isolate;
|
|
93
|
+
}
|
|
94
|
+
:where(.ma-vdialog__thumb:focus-visible) {
|
|
95
|
+
outline: 2px solid currentColor;
|
|
96
|
+
outline-offset: 3px;
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-vdialog__poster) {
|
|
99
|
+
display: block;
|
|
100
|
+
width: 100%;
|
|
101
|
+
height: 100%;
|
|
102
|
+
object-fit: cover;
|
|
103
|
+
scale: 1;
|
|
104
|
+
transition: scale var(--ma-dur-gentle) var(--ma-ease);
|
|
105
|
+
}
|
|
106
|
+
:where(.ma-vdialog__thumb:hover .ma-vdialog__poster) {
|
|
107
|
+
scale: 1.03;
|
|
108
|
+
}
|
|
109
|
+
/* the play button, with a ring breathing behind it on the ambient duration */
|
|
110
|
+
:where(.ma-vdialog__play) {
|
|
111
|
+
position: absolute;
|
|
112
|
+
top: 50%;
|
|
113
|
+
left: 50%;
|
|
114
|
+
display: grid;
|
|
115
|
+
place-items: center;
|
|
116
|
+
width: 4rem;
|
|
117
|
+
height: 4rem;
|
|
118
|
+
border-radius: 999px;
|
|
119
|
+
background: var(--ma-panel);
|
|
120
|
+
color: var(--ma-ink);
|
|
121
|
+
box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.6);
|
|
122
|
+
translate: -50% -50%;
|
|
123
|
+
transition: scale var(--ma-duration) var(--ma-ease);
|
|
124
|
+
}
|
|
125
|
+
:where(.ma-vdialog__play)::before {
|
|
126
|
+
content: '';
|
|
127
|
+
position: absolute;
|
|
128
|
+
inset: 0;
|
|
129
|
+
border-radius: inherit;
|
|
130
|
+
background: var(--ma-panel);
|
|
131
|
+
opacity: 0.5;
|
|
132
|
+
animation: ma-vdialog-ring var(--ma-dur-ambient) var(--ma-ease-out) infinite;
|
|
133
|
+
}
|
|
134
|
+
:where(.ma-vdialog__play svg) {
|
|
135
|
+
position: relative;
|
|
136
|
+
margin-left: 0.15em;
|
|
137
|
+
}
|
|
138
|
+
:where(.ma-vdialog__thumb:hover .ma-vdialog__play),
|
|
139
|
+
:where(.ma-vdialog__thumb:focus-visible .ma-vdialog__play) {
|
|
140
|
+
scale: 1.08;
|
|
141
|
+
}
|
|
142
|
+
/* the dialog is the player: no padding, the frame's ratio, an ink ground */
|
|
143
|
+
:where(.ma-vdialog) .ma-dialog {
|
|
144
|
+
width: min(92vw, 64rem);
|
|
145
|
+
max-width: none;
|
|
146
|
+
overflow: clip;
|
|
147
|
+
background: var(--ma-ink);
|
|
148
|
+
}
|
|
149
|
+
:where(.ma-vdialog) .ma-dialog__panel {
|
|
150
|
+
padding: 0;
|
|
151
|
+
}
|
|
152
|
+
:where(.ma-vdialog) .ma-dialog__close button {
|
|
153
|
+
background: var(--ma-panel);
|
|
154
|
+
color: var(--ma-ink);
|
|
155
|
+
opacity: 0.9;
|
|
156
|
+
}
|
|
157
|
+
:where(.ma-vdialog__frame) {
|
|
158
|
+
position: relative;
|
|
159
|
+
aspect-ratio: var(--ma-vdialog-ratio, 16 / 9);
|
|
160
|
+
background: var(--ma-ink);
|
|
161
|
+
}
|
|
162
|
+
/* without JavaScript an embed has no src: the frame links to the player instead */
|
|
163
|
+
:where(.ma-vdialog__link) {
|
|
164
|
+
position: absolute;
|
|
165
|
+
inset: 0;
|
|
166
|
+
display: grid;
|
|
167
|
+
place-items: center;
|
|
168
|
+
padding: 1rem;
|
|
169
|
+
color: var(--ma-panel);
|
|
170
|
+
text-align: center;
|
|
171
|
+
}
|
|
172
|
+
:where(.ma-vdialog__media) {
|
|
173
|
+
display: block;
|
|
174
|
+
width: 100%;
|
|
175
|
+
height: 100%;
|
|
176
|
+
border: 0;
|
|
177
|
+
background: var(--ma-ink);
|
|
178
|
+
}
|
|
179
|
+
@media (prefers-reduced-motion: reduce) {
|
|
180
|
+
:where(.ma-vdialog__poster),
|
|
181
|
+
:where(.ma-vdialog__play) {
|
|
182
|
+
transition: none;
|
|
183
|
+
scale: 1;
|
|
184
|
+
}
|
|
185
|
+
:where(.ma-vdialog__play)::before {
|
|
186
|
+
animation: none;
|
|
187
|
+
opacity: 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@keyframes ma-vdialog-ring {
|
|
193
|
+
from {
|
|
194
|
+
scale: 1;
|
|
195
|
+
opacity: 0.5;
|
|
196
|
+
}
|
|
197
|
+
to {
|
|
198
|
+
scale: 1.7;
|
|
199
|
+
opacity: 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
</style>
|
|
203
|
+
|
|
204
|
+
<script>
|
|
205
|
+
import { onMount } from '../lib/runtime';
|
|
206
|
+
|
|
207
|
+
onMount<HTMLElement>('[data-ma-vdialog]', (root, { signal }) => {
|
|
208
|
+
const dialog = root.querySelector('dialog') as HTMLDialogElement;
|
|
209
|
+
const media = dialog.querySelector<HTMLVideoElement | HTMLIFrameElement>('.ma-vdialog__media')!;
|
|
210
|
+
const video = media instanceof HTMLVideoElement ? media : null;
|
|
211
|
+
// opening: a file plays inside the click, a user gesture the browser honours. One task later the dialog is open, or
|
|
212
|
+
// opening (a morph in flight sets html[data-ma-vt]); if neither, the click opened nothing (no invoker commands, no
|
|
213
|
+
// polyfill) and nothing may play behind the closed dialog. An embed gets its src then, never while the dialog is shut.
|
|
214
|
+
const start = () => {
|
|
215
|
+
video?.play().catch(() => {});
|
|
216
|
+
setTimeout(() => {
|
|
217
|
+
if (!dialog.open && !document.documentElement.hasAttribute('data-ma-vt')) stop();
|
|
218
|
+
else if (!video) media.setAttribute('src', media.dataset.src!);
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
// closing: a file pauses; an embed is blanked so it stops loading and talking
|
|
222
|
+
const stop = () => {
|
|
223
|
+
if (video) video.pause();
|
|
224
|
+
else media.setAttribute('src', 'about:blank');
|
|
225
|
+
};
|
|
226
|
+
for (const t of document.querySelectorAll<HTMLElement>(`[commandfor="${dialog.id}"][command="show-modal"]`)) t.addEventListener('click', start, { signal });
|
|
227
|
+
dialog.addEventListener('close', stop, { signal });
|
|
228
|
+
signal.addEventListener('abort', stop);
|
|
229
|
+
});
|
|
230
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'VideoDialog',
|
|
5
|
+
slug: 'video-dialog',
|
|
6
|
+
title: 'Video Dialog',
|
|
7
|
+
description: 'A poster with a breathing play button that opens the video in a dialog: a file with native controls, or an embed. The dialog is Dialog (native, invoker-opened, optionally morphing out of the poster); the script does what a closed dialog will not: play on open, pause on close, and for an embed set the iframe src on open and blank it on close so nothing loads or keeps playing behind a closed dialog.',
|
|
8
|
+
summary: 'A video poster with a play button that opens the video in a dialog, and pauses and unloads it on close.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['dialog', 'command'], fallback: 'without invoker commands the poster needs polyfill to open the dialog, and plays nothing until it does; without JavaScript a file plays with its native controls and an embed\'s frame is a link to the player' },
|
|
13
|
+
readout: '<VideoDialog morph>',
|
|
14
|
+
usage: `<VideoDialog id="demo" src={['/demo.webm', '/demo.mp4']} poster="/demo.jpg" title="Product demo" morph tracks={[{ src: '/demo.en.vtt', srclang: 'en', label: 'English' }]} />\n\n<!-- an embed: the iframe gets its src only while the dialog is open -->\n<VideoDialog id="talk" embed src="https://www.youtube-nocookie.com/embed/xyz?autoplay=1" poster="/talk.jpg" title="The talk" />`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'id', type: 'string', description: 'Dialog id; unique per page.' },
|
|
17
|
+
{ name: 'src', type: 'string | string[]', description: 'A video file (an array is several formats, webm then mp4), or with embed the URL of a player page.' },
|
|
18
|
+
{ name: 'poster', type: 'string', description: 'The poster, on the trigger and under the file player.' },
|
|
19
|
+
{ name: 'embed', type: 'boolean', default: 'false', description: 'src is an iframe URL. Its src is set when the dialog opens and blanked when it closes.' },
|
|
20
|
+
{ name: 'morph', type: 'boolean', default: 'false', description: 'Grow out of the poster and return to it (Dialog morph).' },
|
|
21
|
+
{ name: 'title', type: 'string', default: "'Video'", description: 'Name of the dialog and the iframe.' },
|
|
22
|
+
{ name: 'label', type: 'string', default: "'Play video'", description: 'Accessible name of the play button.' },
|
|
23
|
+
{ name: 'ratio', type: 'string', default: "'16 / 9'", description: 'Aspect ratio of the poster and the player.' },
|
|
24
|
+
{ name: 'tracks', type: "{ src: string; srclang: string; label?: string; kind?: 'captions' | 'subtitles' | 'descriptions' | 'chapters'; default?: boolean }[]", default: '[]', description: 'WebVTT text tracks for a file, rendered as <track> (kind defaults to captions). An embed brings its own.' },
|
|
25
|
+
{ name: 'polyfill', type: 'boolean', default: 'false', description: 'Inline the invoker click handler (403 B raw) for browsers without command / commandfor.' },
|
|
26
|
+
],
|
|
27
|
+
reducedMotion: 'The play ring stands still and the poster does not scale; the dialog opens with the 150 ms cross-fade (no morph).',
|
|
28
|
+
clientRouter: 'Bound through the shared runtime; the video is paused (or the embed blanked) before the swap.',
|
|
29
|
+
craft: [
|
|
30
|
+
'The trigger is an invoker button, so the dialog opens with zero script and the file plays with its native controls; the script adds play-on-open because the click is a user gesture the browser will honour. One task after the click it looks again: a dialog that neither opened nor is morphing open (no invoker commands, no polyfill) stops the file at once, so nothing plays behind a closed dialog.',
|
|
31
|
+
'A closed dialog is display: none, and display: none stops nothing: a video keeps its sound, an iframe keeps its player. Measured in three engines before this was written; the close handler exists because of it.',
|
|
32
|
+
'An embed gets its src only while the dialog is open: no third-party player loads on a page that merely shows a poster, and blanking on close is the only way to stop a player you do not control.',
|
|
33
|
+
'The play button breathes on the ambient duration with a ring scaling out behind it. A still poster with a live button reads as "this plays" from across the room.',
|
|
34
|
+
'morph is the Dialog\'s: the poster is the box that opens, and it closes back into it.',
|
|
35
|
+
],
|
|
36
|
+
related: ['dialog', 'hover-video', 'lightbox', 'browser-frame'],
|
|
37
|
+
intents: ['video modal', 'play video in dialog', 'hero video popup', 'youtube embed modal astro', 'video lightbox', 'poster with play button opens video'],
|
|
38
|
+
replaces: ['HeroVideoDialog (Magic UI)', 'VideoModal (Cult UI)', 'Dialog + video (shadcn / Radix)'],
|
|
39
|
+
fixture: { props: { id: 'ma-fixture-vdialog', src: '/x.mp4', poster: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 4 3%22%3E%3Crect width=%224%22 height=%223%22 fill=%22%23888%22/%3E%3C/svg%3E', label: 'Fixture content for VideoDialog' }, expect: 'Fixture content for VideoDialog' },
|
|
40
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* WeightWave — a wave of weight rolls through the letters, zero JS.
|
|
4
|
+
*
|
|
5
|
+
* Each character animates font-weight on its own delay. Needs a variable
|
|
6
|
+
* font with a weight axis to be continuous; static families step between
|
|
7
|
+
* the weights they have. Widths change with weight, so use it on a line
|
|
8
|
+
* you want to breathe, not in a paragraph.
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
11
|
+
|
|
12
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
13
|
+
/** The text. */
|
|
14
|
+
text: string;
|
|
15
|
+
/** Element to render. */
|
|
16
|
+
as?: HTMLTag;
|
|
17
|
+
/** Lightest weight. */
|
|
18
|
+
min?: number;
|
|
19
|
+
/** Heaviest weight. */
|
|
20
|
+
max?: number;
|
|
21
|
+
/** Seconds per full wave at one character. */
|
|
22
|
+
duration?: number;
|
|
23
|
+
/** Delay between neighbouring characters, ms. */
|
|
24
|
+
step?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { text, as: Tag = 'span', min = 300, max = 800, duration = 2.4, step = 80, class: className, style, ...rest } = Astro.props;
|
|
28
|
+
// graphemes, not code points: an emoji with a skin tone or a letter with a combining accent swells as one character
|
|
29
|
+
const chars = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment);
|
|
30
|
+
// the caller's style joins the component's properties instead of replacing them
|
|
31
|
+
const vars = [`--ma-ww-min:${min}`, `--ma-ww-max:${max}`, `--ma-ww-dur:${duration}s`, `--ma-ww-step:${step}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
32
|
+
// dir="auto": the characters are inline-blocks, which bidi orders by the page's direction, so Latin text on a
|
|
33
|
+
// right-to-left page ran back to front. The element takes its direction from its own text instead.
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<Tag class:list={['ma-weight', className]} dir="auto" style={vars} {...rest}>
|
|
37
|
+
{chars.map((c, i) => (c === ' ' ? ' ' : <span class="ma-weight__char" aria-hidden="true" style={`--ma-i:${i}`}>{c}</span>))}
|
|
38
|
+
<span class="ma-sr">{text}</span>
|
|
39
|
+
</Tag>
|
|
40
|
+
|
|
41
|
+
<style is:global>
|
|
42
|
+
@layer components {
|
|
43
|
+
:where(.ma-weight) {
|
|
44
|
+
font-variation-settings: normal;
|
|
45
|
+
white-space: pre-wrap;
|
|
46
|
+
}
|
|
47
|
+
:where(.ma-weight__char) {
|
|
48
|
+
display: inline-block;
|
|
49
|
+
font-weight: var(--ma-ww-min, 300);
|
|
50
|
+
animation: ma-weight var(--ma-ww-dur, 2.4s) var(--ma-ease-in-out) infinite;
|
|
51
|
+
animation-delay: calc(var(--ma-i, 0) * var(--ma-ww-step, 80ms));
|
|
52
|
+
}
|
|
53
|
+
@media (prefers-reduced-motion: reduce) {
|
|
54
|
+
:where(.ma-weight__char) {
|
|
55
|
+
animation: none;
|
|
56
|
+
font-weight: inherit;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@keyframes ma-weight {
|
|
62
|
+
0%,
|
|
63
|
+
100% {
|
|
64
|
+
font-weight: var(--ma-ww-min, 300);
|
|
65
|
+
}
|
|
66
|
+
50% {
|
|
67
|
+
font-weight: var(--ma-ww-max, 800);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'WeightWave',
|
|
5
|
+
slug: 'weight-wave',
|
|
6
|
+
title: 'Weight Wave',
|
|
7
|
+
description: 'A wave of boldness rolls through the letters of a line, each character animating its font weight on its own delay. Pure CSS. Continuous with a variable font; steps between available weights otherwise.',
|
|
8
|
+
summary: 'A wave of boldness rolling through the letters of a line, animating the font weight of a variable font.',
|
|
9
|
+
category: 'text',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['always'],
|
|
12
|
+
support: { baseline: 'widely', needs: ['font-variation'] },
|
|
13
|
+
readout: '<WeightWave min={300} max={800}>',
|
|
14
|
+
usage: `<WeightWave as="h1" text="Variable" />\n<WeightWave text="slow" duration={4} step={140} />`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'text', type: 'string', description: 'The text.' },
|
|
17
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
18
|
+
{ name: 'min', type: 'number', default: '300', description: 'Lightest weight.' },
|
|
19
|
+
{ name: 'max', type: 'number', default: '800', description: 'Heaviest weight.' },
|
|
20
|
+
{ name: 'duration', type: 'number', default: '2.4', description: 'Seconds per full wave at one character.' },
|
|
21
|
+
{ name: 'step', type: 'number', default: '80', description: 'Delay between neighbouring characters, ms. Smaller is a faster-travelling wave.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'Every character sits at the inherited weight.',
|
|
24
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
25
|
+
craft: [
|
|
26
|
+
'font-weight animates the wght axis directly; no font-variation-settings string to parse per frame.',
|
|
27
|
+
'ease-in-out so the wave swells and recedes; a linear wave looks mechanical.',
|
|
28
|
+
'Glyph widths change with weight, which means layout every frame: keep it to one line, and never in running text.',
|
|
29
|
+
'A visually hidden span (.ma-sr) carries the text; the characters are aria-hidden.',
|
|
30
|
+
'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.',
|
|
31
|
+
],
|
|
32
|
+
related: ['split-text', 'text-scramble', 'shiny-text'],
|
|
33
|
+
intents: ['variable font animation', 'font weight wave', 'text weight animation', 'animate font-weight', 'wavy bold text'],
|
|
34
|
+
replaces: ['VariableProximity (React Bits)', 'font-weight hover effects'],
|
|
35
|
+
fixture: { props: { text: 'Fixture content for WeightWave' }, expect: 'ma-sr">Fixture content for WeightWave</span>' },
|
|
36
|
+
} satisfies ComponentMeta;
|