@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,168 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* StickyBanner — an announcement ribbon that sticks to the top and folds
|
|
4
|
+
* away when dismissed, zero JS.
|
|
5
|
+
*
|
|
6
|
+
* The ribbon is a non-modal <dialog open>, so the close button is a form
|
|
7
|
+
* with method="dialog" — every browser closes it, no script. The wrapper
|
|
8
|
+
* is a grid whose only row goes 1fr → 0fr when :has() sees the dialog
|
|
9
|
+
* closed, and the ribbon slides up as it leaves through allow-discrete, so
|
|
10
|
+
* the page below moves up on the preset curve rather than jumping.
|
|
11
|
+
* Without `remember` it comes back on the next page; `remember` inlines
|
|
12
|
+
* ~290 B that read localStorage before paint (no flash) and write it on
|
|
13
|
+
* close, marked data-astro-rerun so ClientRouter runs it on every page.
|
|
14
|
+
*/
|
|
15
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
16
|
+
|
|
17
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
18
|
+
/** Dialog id and, with remember, the storage key. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Show the close button. */
|
|
21
|
+
dismissible?: boolean;
|
|
22
|
+
/** Remember a dismissal in localStorage (ma-banner:<id>) and never render it again — an inline script, measured on the readout. */
|
|
23
|
+
remember?: boolean;
|
|
24
|
+
/** Optional call to action. */
|
|
25
|
+
href?: string;
|
|
26
|
+
cta?: string;
|
|
27
|
+
/** Ribbon colours; default ink on panel text. */
|
|
28
|
+
color?: string;
|
|
29
|
+
textColor?: string;
|
|
30
|
+
/** Accessible name. */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Accessible name of the close button. */
|
|
33
|
+
dismissLabel?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { id, dismissible = true, remember = false, href, cta = 'Learn more', color, textColor, label = 'Announcement', dismissLabel = 'Dismiss', class: className, style, ...rest } = Astro.props;
|
|
37
|
+
const vars = [color && `--ma-banner-bg:${color}`, textColor && `--ma-banner-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
38
|
+
/** Runs where it is rendered, before the next paint: a remembered dismissal closes the dialog and skips the fold; every close is stored. */
|
|
39
|
+
const REMEMBER = `(function(){var s=document.currentScript,w=s.previousElementSibling,d=w.querySelector('dialog'),K='ma-banner:'+d.id;try{if(localStorage.getItem(K)){w.setAttribute('data-gone','');d.close()}}catch(e){}d.addEventListener('close',function(){try{localStorage.setItem(K,'1')}catch(e){}})})()`;
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<div class:list={['ma-banner', className]} style={vars || undefined} {...rest}>
|
|
43
|
+
<dialog open id={id} class="ma-banner__dialog" aria-label={label}>
|
|
44
|
+
<div class="ma-banner__inner">
|
|
45
|
+
<p class="ma-banner__text"><slot /></p>
|
|
46
|
+
{href && <a class="ma-banner__cta" href={href}>{cta} <span aria-hidden="true">→</span></a>}
|
|
47
|
+
{dismissible && (
|
|
48
|
+
<form method="dialog" class="ma-banner__close">
|
|
49
|
+
<button type="submit" aria-label={dismissLabel}>
|
|
50
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
|
|
51
|
+
</button>
|
|
52
|
+
</form>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</dialog>
|
|
56
|
+
</div>
|
|
57
|
+
{/* data-astro-rerun: ClientRouter skips an inline script whose text the page it leaves already ran, and this one is the
|
|
58
|
+
same on every page, so without it the next page rendered the ribbon open again */}
|
|
59
|
+
{remember && <script is:inline data-astro-rerun set:html={REMEMBER} />}
|
|
60
|
+
|
|
61
|
+
<style is:global>
|
|
62
|
+
@layer components {
|
|
63
|
+
/* the wrapper is the sticky element and the fold: one grid row from 1fr to 0fr */
|
|
64
|
+
:where(.ma-banner) {
|
|
65
|
+
position: sticky;
|
|
66
|
+
top: 0;
|
|
67
|
+
z-index: 30;
|
|
68
|
+
display: grid;
|
|
69
|
+
grid-template-rows: 1fr;
|
|
70
|
+
transition: grid-template-rows var(--ma-duration) var(--ma-ease);
|
|
71
|
+
}
|
|
72
|
+
:where(.ma-banner:has(.ma-banner__dialog:not([open]))) {
|
|
73
|
+
grid-template-rows: 0fr;
|
|
74
|
+
}
|
|
75
|
+
:where(.ma-banner[data-gone]) {
|
|
76
|
+
transition: none;
|
|
77
|
+
}
|
|
78
|
+
:where(.ma-banner__dialog) {
|
|
79
|
+
position: static;
|
|
80
|
+
width: 100%;
|
|
81
|
+
max-width: none;
|
|
82
|
+
min-height: 0;
|
|
83
|
+
margin: 0;
|
|
84
|
+
padding: 0;
|
|
85
|
+
border: 0;
|
|
86
|
+
background: none;
|
|
87
|
+
color: inherit;
|
|
88
|
+
overflow: clip;
|
|
89
|
+
opacity: 1;
|
|
90
|
+
translate: 0 0;
|
|
91
|
+
transition:
|
|
92
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out),
|
|
93
|
+
translate var(--ma-duration) var(--ma-ease-in),
|
|
94
|
+
display var(--ma-duration) allow-discrete;
|
|
95
|
+
}
|
|
96
|
+
:where(.ma-banner__dialog:not([open])) {
|
|
97
|
+
opacity: 0;
|
|
98
|
+
translate: 0 -100%;
|
|
99
|
+
}
|
|
100
|
+
:where(.ma-banner__inner) {
|
|
101
|
+
position: relative;
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-wrap: wrap;
|
|
104
|
+
align-items: center;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
gap: 0.25rem 1rem;
|
|
107
|
+
padding: 0.625rem 3rem;
|
|
108
|
+
background: var(--ma-banner-bg, var(--ma-ink));
|
|
109
|
+
color: var(--ma-banner-fg, var(--ma-panel));
|
|
110
|
+
font-size: 0.875rem;
|
|
111
|
+
line-height: 1.4;
|
|
112
|
+
text-align: center;
|
|
113
|
+
}
|
|
114
|
+
:where(.ma-banner__text) {
|
|
115
|
+
margin: 0;
|
|
116
|
+
}
|
|
117
|
+
:where(.ma-banner__cta) {
|
|
118
|
+
color: inherit;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
text-decoration: underline;
|
|
121
|
+
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
|
122
|
+
text-underline-offset: 0.2em;
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
transition: text-decoration-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
125
|
+
}
|
|
126
|
+
:where(.ma-banner__cta:hover) {
|
|
127
|
+
text-decoration-color: currentColor;
|
|
128
|
+
}
|
|
129
|
+
:where(.ma-banner__close) {
|
|
130
|
+
position: absolute;
|
|
131
|
+
top: 50%;
|
|
132
|
+
right: 0.75rem;
|
|
133
|
+
translate: 0 -50%;
|
|
134
|
+
}
|
|
135
|
+
:where(.ma-banner__close button) {
|
|
136
|
+
display: grid;
|
|
137
|
+
place-items: center;
|
|
138
|
+
width: 1.75rem;
|
|
139
|
+
height: 1.75rem;
|
|
140
|
+
border: 0;
|
|
141
|
+
border-radius: 999px;
|
|
142
|
+
background: none;
|
|
143
|
+
color: inherit;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
opacity: 0.7;
|
|
146
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out), background-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
147
|
+
}
|
|
148
|
+
:where(.ma-banner__close button:hover) {
|
|
149
|
+
opacity: 1;
|
|
150
|
+
background: color-mix(in srgb, currentColor 14%, transparent);
|
|
151
|
+
}
|
|
152
|
+
:where(.ma-banner__close button:focus-visible),
|
|
153
|
+
:where(.ma-banner__cta:focus-visible) {
|
|
154
|
+
outline: 2px solid currentColor;
|
|
155
|
+
outline-offset: 2px;
|
|
156
|
+
}
|
|
157
|
+
@media (prefers-reduced-motion: reduce) {
|
|
158
|
+
:where(.ma-banner),
|
|
159
|
+
:where(.ma-banner__dialog) {
|
|
160
|
+
transition-duration: var(--ma-duration-fast);
|
|
161
|
+
transition-timing-function: linear;
|
|
162
|
+
}
|
|
163
|
+
:where(.ma-banner__dialog:not([open])) {
|
|
164
|
+
translate: 0 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'StickyBanner',
|
|
5
|
+
slug: 'sticky-banner',
|
|
6
|
+
title: 'Sticky Banner',
|
|
7
|
+
description: 'An announcement ribbon that sticks to the top of the page and folds away when dismissed, the content below sliding up on the preset curve. Zero JavaScript: a non-modal dialog closed by a form with method="dialog", a grid row that :has() collapses, and an exit through allow-discrete. remember inlines 286 B raw that keep it closed across pages.',
|
|
8
|
+
summary: 'An announcement banner that sticks to the top and folds away when dismissed, the page sliding up after it.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['load', 'click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['dialog', 'has', 'allow-discrete', 'grid-rows-animation'], fallback: 'without allow-discrete the ribbon disappears at once; the row still folds on :has()' },
|
|
13
|
+
readout: '<StickyBanner id="launch" remember>',
|
|
14
|
+
usage: `<StickyBanner id="launch" href="/changelog/" cta="See what shipped">\n <strong>New</strong>: dark mode for every chart.\n</StickyBanner>\n\n<!-- stays dismissed on the next visit -->\n<StickyBanner id="launch" remember>…</StickyBanner>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'id', type: 'string', description: 'Dialog id; with remember, the localStorage key is ma-banner:<id>. Change it for a new announcement.' },
|
|
17
|
+
{ name: 'dismissible', type: 'boolean', default: 'true', description: 'Render the close button.' },
|
|
18
|
+
{ name: 'remember', type: 'boolean', default: 'false', description: 'Keep a dismissal in localStorage and never render the ribbon again on this browser: an inline script of 286 B raw (measured from the string that is inlined) that runs where the banner is, before paint, so nothing flashes.' },
|
|
19
|
+
{ name: 'href', type: 'string', description: 'A call-to-action link.' },
|
|
20
|
+
{ name: 'cta', type: 'string', default: "'Learn more'", description: 'Its label.' },
|
|
21
|
+
{ name: 'color', type: 'string', description: 'Ribbon colour; default the ink token.' },
|
|
22
|
+
{ name: 'textColor', type: 'string', description: 'Text colour; default the panel token.' },
|
|
23
|
+
{ name: 'label', type: 'string', default: "'Announcement'", description: 'Accessible name of the dialog.' },
|
|
24
|
+
{ name: 'dismissLabel', type: 'string', default: "'Dismiss'", description: 'Accessible name of the close button, for a page in another language. The arrow after the call to action is decorative and hidden from screen readers.' },
|
|
25
|
+
],
|
|
26
|
+
reducedMotion: 'The row folds in 150 ms without the slide.',
|
|
27
|
+
clientRouter: 'Native; nothing to rebind. Without remember every page renders the ribbon again; with it the inline script closes it before paint on every page. The script carries data-astro-rerun: ClientRouter skips an inline script whose text it has already run, and this one is the same on every page.',
|
|
28
|
+
craft: [
|
|
29
|
+
'A non-modal <dialog open> gives a close button for free (a form with method="dialog") and a state (the open attribute) that CSS can read with :has(). No class, no script.',
|
|
30
|
+
'The fold is the Accordion trick turned around: the wrapper is a grid whose row goes from 1fr to 0fr, so the page below slides up on the preset curve instead of jumping when the ribbon leaves.',
|
|
31
|
+
'The ribbon itself slides up and fades on the exit curve while its display transitions with allow-discrete, so it is still there to be animated.',
|
|
32
|
+
'remember is the only case that needs a script and it is inline on purpose: it has to run before the first paint of the page it is on, like ThemeScript, and it is 286 B raw, measured from the same string that is inlined.',
|
|
33
|
+
'Sticky at z-index 30 sits under a site header at 40 and over content: the ribbon belongs to the page, not the chrome.',
|
|
34
|
+
],
|
|
35
|
+
related: ['dialog', 'nav-bar', 'toast'],
|
|
36
|
+
intents: ['announcement banner', 'sticky top banner dismissible', 'promo bar', 'cookie bar without javascript', 'site notice ribbon', 'banner that remembers dismissal'],
|
|
37
|
+
replaces: ['Banner (Aceternity)', 'AnnouncementBar (Tailwind Plus)', 'Alert dismissible (shadcn / Radix)'],
|
|
38
|
+
fixture: {
|
|
39
|
+
props: { id: 'ma-fixture-banner' },
|
|
40
|
+
slot: 'Fixture content for StickyBanner',
|
|
41
|
+
expect: 'Fixture content for StickyBanner',
|
|
42
|
+
// a page in another language: its word reaches the close button's name
|
|
43
|
+
variants: { dismissLabel: { props: { dismissLabel: 'Schließen' }, expect: 'aria-label="Schließen"' } },
|
|
44
|
+
},
|
|
45
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* StickyReveal — steps on the left scroll by, one visual on the right
|
|
4
|
+
* stays put and changes with them. Zero JS: each step names its own view
|
|
5
|
+
* timeline, the visuals are stacked in one sticky grid cell, and each
|
|
6
|
+
* visual's opacity and travel are bound to its step's timeline through
|
|
7
|
+
* timeline-scope on the container. Where scroll timelines are missing, or
|
|
8
|
+
* below the layout's width, the steps and visuals interleave in one column
|
|
9
|
+
* (display: contents + order), so every step still meets its own visual.
|
|
10
|
+
* Text steps are props; visuals are named slots, visual-1 … visual-8.
|
|
11
|
+
*/
|
|
12
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
13
|
+
|
|
14
|
+
interface Step {
|
|
15
|
+
title: string;
|
|
16
|
+
/** Plain text or HTML. */
|
|
17
|
+
body?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
21
|
+
steps: Step[];
|
|
22
|
+
/** Sticky offset from the top of the scrollport. */
|
|
23
|
+
top?: string;
|
|
24
|
+
/** Height of each step's slot and of the sticky visual. */
|
|
25
|
+
height?: string;
|
|
26
|
+
/** `auto`: two columns from 40rem of container width; `sticky`: always two columns; `stack`: always one. */
|
|
27
|
+
layout?: 'auto' | 'sticky' | 'stack';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { steps, top = '10vh', height = '70vh', layout = 'auto', class: className, style, ...rest } = Astro.props;
|
|
31
|
+
const list = steps.slice(0, 8);
|
|
32
|
+
const vars = [`--ma-sticky-top:${top}`, `--ma-sticky-h:${height}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<div class:list={['ma-sticky', className]} data-layout={layout} style={vars} {...rest}>
|
|
36
|
+
<div class="ma-sticky__steps">
|
|
37
|
+
{list.map((s, i) => (
|
|
38
|
+
<div class="ma-sticky__step" style={`--ma-sticky-i:${i + 1}`}>
|
|
39
|
+
<h3 class="ma-sticky__title">{s.title}</h3>
|
|
40
|
+
{s.body && <p class="ma-sticky__body" set:html={s.body} />}
|
|
41
|
+
</div>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
<div class="ma-sticky__visuals">
|
|
45
|
+
{list.map((_, i) => (
|
|
46
|
+
<div class="ma-sticky__visual" style={`--ma-sticky-i:${i + 1}`}>
|
|
47
|
+
<slot name={`visual-${i + 1}`} />
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<style is:global>
|
|
54
|
+
@layer components {
|
|
55
|
+
/* The two tracks are declared on the root in every layout. An element never answers its own container query, so a
|
|
56
|
+
layout="auto" root that switched its own columns at 40rem queried whatever container sat above it (none, as a
|
|
57
|
+
rule) and never went to two columns; the rules below only switch its children, which do see it. */
|
|
58
|
+
:where(.ma-sticky) {
|
|
59
|
+
container-type: inline-size;
|
|
60
|
+
display: grid;
|
|
61
|
+
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
|
62
|
+
gap: 1.5rem;
|
|
63
|
+
timeline-scope: --ma-sticky-1, --ma-sticky-2, --ma-sticky-3, --ma-sticky-4, --ma-sticky-5, --ma-sticky-6, --ma-sticky-7, --ma-sticky-8;
|
|
64
|
+
}
|
|
65
|
+
/* stack: steps and visuals interleave through order across both tracks; the columns are display: contents */
|
|
66
|
+
:where(.ma-sticky__steps),
|
|
67
|
+
:where(.ma-sticky__visuals) {
|
|
68
|
+
display: contents;
|
|
69
|
+
}
|
|
70
|
+
:where(.ma-sticky__step) {
|
|
71
|
+
grid-column: 1 / -1;
|
|
72
|
+
order: calc(var(--ma-sticky-i, 1) * 2 - 1);
|
|
73
|
+
}
|
|
74
|
+
:where(.ma-sticky__visual) {
|
|
75
|
+
grid-column: 1 / -1;
|
|
76
|
+
order: calc(var(--ma-sticky-i, 1) * 2);
|
|
77
|
+
min-height: 12rem;
|
|
78
|
+
}
|
|
79
|
+
:where(.ma-sticky__title) {
|
|
80
|
+
margin: 0;
|
|
81
|
+
font-size: 1.375em;
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
letter-spacing: -0.02em;
|
|
84
|
+
line-height: 1.2;
|
|
85
|
+
}
|
|
86
|
+
:where(.ma-sticky__body) {
|
|
87
|
+
margin: 0.5em 0 0;
|
|
88
|
+
opacity: 0.7;
|
|
89
|
+
}
|
|
90
|
+
:where(.ma-sticky__visual > :first-child) {
|
|
91
|
+
width: 100%;
|
|
92
|
+
height: 100%;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* sticky: two columns, visuals stacked in one sticky cell, each bound to its step's passage */
|
|
96
|
+
@supports (animation-timeline: view()) and (timeline-scope: --a) {
|
|
97
|
+
@container (min-width: 40rem) {
|
|
98
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__steps),
|
|
99
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visuals) {
|
|
100
|
+
display: grid;
|
|
101
|
+
align-self: start;
|
|
102
|
+
}
|
|
103
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step) {
|
|
104
|
+
order: 0;
|
|
105
|
+
min-height: var(--ma-sticky-h, 70vh);
|
|
106
|
+
display: grid;
|
|
107
|
+
align-content: center;
|
|
108
|
+
animation: ma-sticky-step linear both;
|
|
109
|
+
animation-range: cover 0% cover 100%;
|
|
110
|
+
}
|
|
111
|
+
:where(html) :where(.ma-sticky[data-layout='auto'] .ma-sticky__step) {
|
|
112
|
+
animation-timeline: view();
|
|
113
|
+
}
|
|
114
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(1)) { view-timeline-name: --ma-sticky-1; }
|
|
115
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(2)) { view-timeline-name: --ma-sticky-2; }
|
|
116
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(3)) { view-timeline-name: --ma-sticky-3; }
|
|
117
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(4)) { view-timeline-name: --ma-sticky-4; }
|
|
118
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(5)) { view-timeline-name: --ma-sticky-5; }
|
|
119
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(6)) { view-timeline-name: --ma-sticky-6; }
|
|
120
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(7)) { view-timeline-name: --ma-sticky-7; }
|
|
121
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(8)) { view-timeline-name: --ma-sticky-8; }
|
|
122
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visuals) {
|
|
123
|
+
position: sticky;
|
|
124
|
+
top: var(--ma-sticky-top, 10vh);
|
|
125
|
+
height: var(--ma-sticky-h, 70vh);
|
|
126
|
+
}
|
|
127
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual) {
|
|
128
|
+
order: 0;
|
|
129
|
+
grid-area: 1 / 1;
|
|
130
|
+
min-height: 0;
|
|
131
|
+
animation: ma-sticky-visual linear both;
|
|
132
|
+
animation-range: cover 0% cover 100%;
|
|
133
|
+
}
|
|
134
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:first-child) { animation-name: ma-sticky-visual-first; }
|
|
135
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:last-child) { animation-name: ma-sticky-visual-last; }
|
|
136
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:only-child) { animation-name: none; }
|
|
137
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(1)) { animation-timeline: --ma-sticky-1; }
|
|
138
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(2)) { animation-timeline: --ma-sticky-2; }
|
|
139
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(3)) { animation-timeline: --ma-sticky-3; }
|
|
140
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(4)) { animation-timeline: --ma-sticky-4; }
|
|
141
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(5)) { animation-timeline: --ma-sticky-5; }
|
|
142
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(6)) { animation-timeline: --ma-sticky-6; }
|
|
143
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(7)) { animation-timeline: --ma-sticky-7; }
|
|
144
|
+
:where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(8)) { animation-timeline: --ma-sticky-8; }
|
|
145
|
+
}
|
|
146
|
+
/* forced: the same rules without the width condition */
|
|
147
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__steps),
|
|
148
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visuals) {
|
|
149
|
+
display: grid;
|
|
150
|
+
align-self: start;
|
|
151
|
+
}
|
|
152
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step) {
|
|
153
|
+
order: 0;
|
|
154
|
+
min-height: var(--ma-sticky-h, 70vh);
|
|
155
|
+
display: grid;
|
|
156
|
+
align-content: center;
|
|
157
|
+
animation: ma-sticky-step linear both;
|
|
158
|
+
animation-range: cover 0% cover 100%;
|
|
159
|
+
}
|
|
160
|
+
:where(html) :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step) {
|
|
161
|
+
animation-timeline: view();
|
|
162
|
+
}
|
|
163
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(1)) { view-timeline-name: --ma-sticky-1; }
|
|
164
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(2)) { view-timeline-name: --ma-sticky-2; }
|
|
165
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(3)) { view-timeline-name: --ma-sticky-3; }
|
|
166
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(4)) { view-timeline-name: --ma-sticky-4; }
|
|
167
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(5)) { view-timeline-name: --ma-sticky-5; }
|
|
168
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(6)) { view-timeline-name: --ma-sticky-6; }
|
|
169
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(7)) { view-timeline-name: --ma-sticky-7; }
|
|
170
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(8)) { view-timeline-name: --ma-sticky-8; }
|
|
171
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visuals) {
|
|
172
|
+
position: sticky;
|
|
173
|
+
top: var(--ma-sticky-top, 10vh);
|
|
174
|
+
height: var(--ma-sticky-h, 70vh);
|
|
175
|
+
}
|
|
176
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual) {
|
|
177
|
+
order: 0;
|
|
178
|
+
grid-area: 1 / 1;
|
|
179
|
+
min-height: 0;
|
|
180
|
+
animation: ma-sticky-visual linear both;
|
|
181
|
+
animation-range: cover 0% cover 100%;
|
|
182
|
+
}
|
|
183
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:first-child) { animation-name: ma-sticky-visual-first; }
|
|
184
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:last-child) { animation-name: ma-sticky-visual-last; }
|
|
185
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:only-child) { animation-name: none; }
|
|
186
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(1)) { animation-timeline: --ma-sticky-1; }
|
|
187
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(2)) { animation-timeline: --ma-sticky-2; }
|
|
188
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(3)) { animation-timeline: --ma-sticky-3; }
|
|
189
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(4)) { animation-timeline: --ma-sticky-4; }
|
|
190
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(5)) { animation-timeline: --ma-sticky-5; }
|
|
191
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(6)) { animation-timeline: --ma-sticky-6; }
|
|
192
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(7)) { animation-timeline: --ma-sticky-7; }
|
|
193
|
+
:where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(8)) { animation-timeline: --ma-sticky-8; }
|
|
194
|
+
}
|
|
195
|
+
/* Reduced motion: the visuals still hand over with their steps, in place (no travel), and the step text stays at
|
|
196
|
+
full strength. A stepped cut used to share its keyframes with the steps, so every step but the current one was
|
|
197
|
+
invisible, and it left scroll positions where no visual showed at all. */
|
|
198
|
+
@media (prefers-reduced-motion: reduce) {
|
|
199
|
+
:where(.ma-sticky) {
|
|
200
|
+
--ma-travel-section: 0px;
|
|
201
|
+
}
|
|
202
|
+
:where(.ma-sticky__step) {
|
|
203
|
+
animation: none;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* the visual of the step in the middle of the scrollport is the one that shows */
|
|
209
|
+
@keyframes ma-sticky-visual {
|
|
210
|
+
0% {
|
|
211
|
+
opacity: 0;
|
|
212
|
+
translate: 0 var(--ma-travel-section);
|
|
213
|
+
}
|
|
214
|
+
30%,
|
|
215
|
+
70% {
|
|
216
|
+
opacity: 1;
|
|
217
|
+
translate: 0 0;
|
|
218
|
+
}
|
|
219
|
+
100% {
|
|
220
|
+
opacity: 0;
|
|
221
|
+
translate: 0 calc(-1 * var(--ma-travel-section));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
@keyframes ma-sticky-visual-first {
|
|
225
|
+
0%,
|
|
226
|
+
70% {
|
|
227
|
+
opacity: 1;
|
|
228
|
+
translate: 0 0;
|
|
229
|
+
}
|
|
230
|
+
100% {
|
|
231
|
+
opacity: 0;
|
|
232
|
+
translate: 0 calc(-1 * var(--ma-travel-section));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
@keyframes ma-sticky-visual-last {
|
|
236
|
+
0% {
|
|
237
|
+
opacity: 0;
|
|
238
|
+
translate: 0 var(--ma-travel-section);
|
|
239
|
+
}
|
|
240
|
+
30%,
|
|
241
|
+
100% {
|
|
242
|
+
opacity: 1;
|
|
243
|
+
translate: 0 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
@keyframes ma-sticky-step {
|
|
247
|
+
0%,
|
|
248
|
+
100% {
|
|
249
|
+
opacity: 0.35;
|
|
250
|
+
}
|
|
251
|
+
35%,
|
|
252
|
+
65% {
|
|
253
|
+
opacity: 1;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'StickyReveal',
|
|
5
|
+
slug: 'sticky-reveal',
|
|
6
|
+
title: 'Sticky Reveal',
|
|
7
|
+
description: 'Steps on the left scroll by; one visual on the right stays put and changes with them. Each step names its own view timeline, the visuals are stacked in a sticky cell, and each visual\'s opacity and travel are bound to its step through timeline-scope. Without scroll timelines, or in a narrow container, the steps and visuals interleave in one column so every step still meets its own visual. Zero JavaScript.',
|
|
8
|
+
summary: 'Scroll steps beside one sticky visual that changes with each step, falling back to one column when narrow.',
|
|
9
|
+
category: 'scroll',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['scroll'],
|
|
12
|
+
support: { baseline: 'limited', needs: ['scroll-timeline', 'timeline-scope'], fallback: 'one column: each step followed by its own visual (Firefox, and every browser below 40 rem of container width)' },
|
|
13
|
+
readout: '<StickyReveal steps={[…]} top="10vh">',
|
|
14
|
+
usage: `<StickyReveal steps={[{ title: 'Write', body: 'One component, one file.' }, { title: 'Measure', body: 'Every byte from a real build.' }, { title: 'Ship', body: 'Nothing to hydrate.' }]}>\n <img slot="visual-1" src="/write.png" alt="" />\n <img slot="visual-2" src="/measure.png" alt="" />\n <img slot="visual-3" src="/ship.png" alt="" />\n</StickyReveal>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'steps', type: '{ title: string; body?: string }[]', description: 'Up to eight steps; body may be HTML.' },
|
|
17
|
+
{ name: 'top', type: 'string', default: "'10vh'", description: 'Sticky offset from the top of the scrollport.' },
|
|
18
|
+
{ name: 'height', type: 'string', default: "'70vh'", description: 'Height of each step\'s slot and of the sticky visual.' },
|
|
19
|
+
{ name: 'layout', type: "'auto' | 'sticky' | 'stack'", default: "'auto'", description: 'auto: two columns from 40 rem of container width; sticky: always; stack: never.' },
|
|
20
|
+
],
|
|
21
|
+
reducedMotion: 'The visuals still hand over with their steps, crossfading in place with no travel, and the step text no longer dims.',
|
|
22
|
+
clientRouter: 'CSS-only; the timelines are recomputed with the new page.',
|
|
23
|
+
craft: [
|
|
24
|
+
'timeline-scope on the container lets a visual read a sibling column\'s timeline: the visual for step 3 is literally animated by step 3\'s passage, so they can never drift apart.',
|
|
25
|
+
'A visual is fully on from 30 % to 70 % of its step\'s cover range and hands over in the outer 30 %s; the first and last visuals hold at the ends so the section never starts or ends empty.',
|
|
26
|
+
'The stacked fallback is not "the first visual only": display: contents on both columns and an order per item interleave the DOM into step, visual, step, visual, which is what a phone should show anyway.',
|
|
27
|
+
'The width switch is a container query, not a media query, so a StickyReveal in a sidebar stacks while one across the page sticks. The component is its own container, and an element never answers its own query, so the two tracks are declared on it in every layout and only its children switch.',
|
|
28
|
+
],
|
|
29
|
+
related: ['card-stack', 'scroll-scrub', 'horizontal-scroll'],
|
|
30
|
+
intents: ['sticky scroll reveal', 'scroll steps with sticky image', 'features scroll with changing image', 'sticky scroll section', 'scrollytelling astro'],
|
|
31
|
+
replaces: ['StickyScrollReveal (Aceternity)', 'sticky scroll section (Tailwind Plus)', 'ScrollStack (React Bits)'],
|
|
32
|
+
fixture: { props: { steps: [{ title: 'Fixture content for StickyReveal' }, { title: 'Two' }] }, expect: 'Fixture content for StickyReveal' },
|
|
33
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* StrokeDraw — the outline of a headline draws itself, then the fill comes
|
|
4
|
+
* in. -webkit-text-stroke cannot be drawn as a stroke, so the text is an
|
|
5
|
+
* SVG <text>, one <tspan> per character, each stroked with a dash longer
|
|
6
|
+
* than any glyph's outline and offset to zero on a per-index delay; the
|
|
7
|
+
* fill fades in once the stroke has landed. pathLength is not defined for
|
|
8
|
+
* <text> in any engine, so the dash is em-based. Composes Reveal for the
|
|
9
|
+
* trigger; zero script of its own. The server HTML holds the final text.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
12
|
+
import Reveal from './Reveal.astro';
|
|
13
|
+
|
|
14
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
15
|
+
/** The text — one line. */
|
|
16
|
+
text: string;
|
|
17
|
+
/** Element to render. */
|
|
18
|
+
as?: HTMLTag;
|
|
19
|
+
/** Stroke width, relative to a 100-unit font size (2 ≈ 2% of the cap height). */
|
|
20
|
+
width?: number;
|
|
21
|
+
/** Dash length per glyph in em; the outline of any glyph must fit inside it. */
|
|
22
|
+
dash?: number;
|
|
23
|
+
/** Stroke colour. */
|
|
24
|
+
color?: string;
|
|
25
|
+
/** Fill after the stroke. false leaves the outline. */
|
|
26
|
+
fill?: boolean;
|
|
27
|
+
/** Draw duration per glyph in ms. Unset: the preset's --ma-duration-slow. */
|
|
28
|
+
duration?: number;
|
|
29
|
+
/** Delay between neighbouring glyphs in ms. Unset: the preset's --ma-stagger. */
|
|
30
|
+
step?: number;
|
|
31
|
+
/** Delay in ms before the first glyph. */
|
|
32
|
+
delay?: number;
|
|
33
|
+
/** Play once and stay. */
|
|
34
|
+
once?: boolean;
|
|
35
|
+
/** Fraction visible to trigger. */
|
|
36
|
+
threshold?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { text, as = 'span', width = 2, dash = 5, color = 'currentColor', fill = true, duration, step, delay, once, threshold, class: className, style, ...rest } = Astro.props;
|
|
40
|
+
|
|
41
|
+
// Advance widths in em, rough Inter metrics: enough to size the viewBox; textLength then justifies the spacing.
|
|
42
|
+
const advance = (c: string) => (c === ' ' ? 0.28 : /[ilj.,'!|:;]/.test(c) ? 0.3 : /[frt1I\[\]()]/.test(c) ? 0.38 : /[mwMW]/.test(c) ? 0.9 : /[A-Z0-9]/.test(c) ? 0.68 : 0.56);
|
|
43
|
+
const FS = 100;
|
|
44
|
+
// one tspan per grapheme, not per code point: a combining accent in a tspan of its own is drawn apart from its letter
|
|
45
|
+
const chars = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment);
|
|
46
|
+
const w = Math.round(chars.reduce((n, c) => n + advance(c), 0) * FS);
|
|
47
|
+
const h = Math.round(FS * 1.25);
|
|
48
|
+
const vars = [`--ma-stroke-w:${width}`, `--ma-stroke-dash:${dash}em`, `--ma-stroke-color:${color}`, !fill && '--ma-stroke-fill:transparent', duration !== undefined && `--ma-stroke-dur:${duration}ms`, step !== undefined && `--ma-stroke-step:${step}ms`, typeof style === 'string' ? style : '']
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(';');
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
<Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-stroke', className].filter(Boolean).join(' ')} style={vars} {...rest}>
|
|
54
|
+
<svg class="ma-stroke__svg" viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
|
|
55
|
+
<text x="0" y={FS} font-size={FS} textLength={w} lengthAdjust="spacing">{chars.map((c, i) => <tspan class="ma-stroke__glyph" style={`--ma-i:${i}`}>{c}</tspan>)}</text>
|
|
56
|
+
</svg>
|
|
57
|
+
<span class="ma-sr">{text}</span>
|
|
58
|
+
</Reveal>
|
|
59
|
+
|
|
60
|
+
<style is:global>
|
|
61
|
+
@layer components {
|
|
62
|
+
:where(.ma-stroke) {
|
|
63
|
+
display: block;
|
|
64
|
+
}
|
|
65
|
+
/* direction: ltr. The line is laid out on the server from x = 0 with textLength, which is a left-to-right layout;
|
|
66
|
+
under a right-to-left page the text started at 0 and ran off the left edge (or reordered its punctuation). */
|
|
67
|
+
:where(.ma-stroke__svg) {
|
|
68
|
+
display: block;
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: auto;
|
|
71
|
+
overflow: visible;
|
|
72
|
+
direction: ltr;
|
|
73
|
+
font-family: inherit;
|
|
74
|
+
font-weight: inherit;
|
|
75
|
+
letter-spacing: inherit;
|
|
76
|
+
}
|
|
77
|
+
:where(.ma-stroke__glyph) {
|
|
78
|
+
fill: var(--ma-stroke-fill, currentColor);
|
|
79
|
+
stroke: var(--ma-stroke-color, currentColor);
|
|
80
|
+
stroke-width: var(--ma-stroke-w, 2);
|
|
81
|
+
stroke-linejoin: round;
|
|
82
|
+
stroke-linecap: round;
|
|
83
|
+
paint-order: stroke;
|
|
84
|
+
stroke-dasharray: var(--ma-stroke-dash, 5em);
|
|
85
|
+
stroke-dashoffset: 0;
|
|
86
|
+
transition:
|
|
87
|
+
stroke-dashoffset var(--ma-stroke-dur, var(--ma-duration-slow)) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-stroke-step, var(--ma-stagger))),
|
|
88
|
+
fill var(--ma-duration) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-stroke-step, var(--ma-stagger)) + var(--ma-stroke-dur, var(--ma-duration-slow)));
|
|
89
|
+
}
|
|
90
|
+
/* Under the JS gate the outlines start undrawn and unfilled; Reveal's .ma-in lets the transitions run. */
|
|
91
|
+
:where([data-ma-js] .ma-stroke:not(.ma-in) .ma-stroke__glyph) {
|
|
92
|
+
stroke-dashoffset: var(--ma-stroke-dash, 5em);
|
|
93
|
+
fill: transparent;
|
|
94
|
+
transition: none;
|
|
95
|
+
}
|
|
96
|
+
@media (prefers-reduced-motion: reduce) {
|
|
97
|
+
:where(.ma-stroke__glyph) {
|
|
98
|
+
transition: none;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/* Print: drawn and filled. A headline printed before it scrolled into view was an empty box. */
|
|
102
|
+
@media print {
|
|
103
|
+
:where(.ma-stroke .ma-stroke__glyph) {
|
|
104
|
+
stroke-dashoffset: 0;
|
|
105
|
+
fill: var(--ma-stroke-fill, currentColor);
|
|
106
|
+
transition: none;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'StrokeDraw',
|
|
5
|
+
slug: 'stroke-draw',
|
|
6
|
+
title: 'Stroke Draw',
|
|
7
|
+
description:
|
|
8
|
+
'A headline\'s outline draws itself glyph by glyph when it scrolls into view, then the fill comes in. SVG text with one stroked <tspan> per character, a dash longer than any outline offset to zero on a per-index delay. Composes Reveal; no script of its own. The server HTML holds the finished text.',
|
|
9
|
+
summary: 'A headline whose outline draws itself letter by letter as it scrolls into view, then fills.',
|
|
10
|
+
category: 'text',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['scroll'],
|
|
13
|
+
support: { baseline: 'widely', needs: [] },
|
|
14
|
+
readout: '<StrokeDraw text="Draw.">',
|
|
15
|
+
usage: `<StrokeDraw as="h1" text="Measured." class="text-6xl font-bold" />\n<StrokeDraw text="Outline only" fill={false} width={3} color="var(--ma-ink)" />`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'text', type: 'string', description: 'The text, one line. Its width follows the container; the viewBox is sized from rough glyph advances and textLength justifies the spacing.' },
|
|
18
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
19
|
+
{ name: 'width', type: 'number', default: '2', description: 'Stroke width relative to a 100-unit font size. 1.5–3 reads as a pen; more reads as a marker.' },
|
|
20
|
+
{ name: 'dash', type: 'number', default: '5', description: 'Dash length per glyph in em. Every glyph outline must fit inside it (Inter\'s run 2 to 4.5 em), and a glyph draws faster the shorter its outline is relative to the dash.' },
|
|
21
|
+
{ name: 'color', type: 'string', default: "'currentColor'", description: 'Stroke colour.' },
|
|
22
|
+
{ name: 'fill', type: 'boolean', default: 'true', description: 'Fill after the stroke lands; false leaves the outline.' },
|
|
23
|
+
{ name: 'duration', type: 'number', default: 'preset (700)', description: 'Draw duration per glyph in ms. Unset, the preset\'s --ma-duration-slow.' },
|
|
24
|
+
{ name: 'step', type: 'number', default: 'preset (ui: 60)', description: 'Delay between neighbouring glyphs in ms.' },
|
|
25
|
+
{ name: 'delay', type: 'number', default: '0', description: 'Delay before the first glyph.' },
|
|
26
|
+
{ name: 'once', type: 'boolean', default: 'true', description: 'Play once and stay.' },
|
|
27
|
+
{ name: 'threshold', type: 'number', default: '0.2', description: 'Fraction visible to trigger.' },
|
|
28
|
+
],
|
|
29
|
+
reducedMotion: 'The text appears drawn and filled at once.',
|
|
30
|
+
clientRouter: 'Inherits Reveal: rebinds after every navigation.',
|
|
31
|
+
craft: [
|
|
32
|
+
'pathLength does not apply to <text> in any engine (SVG 2 defines it for shapes), so the dash cannot be normalised to 1; it is 5 em per glyph instead, an upper bound on any Latin outline, and every glyph finishes inside the same duration.',
|
|
33
|
+
'One <tspan> per character (a grapheme, so a letter with a combining accent is drawn whole) with its own delay: the headline is written, not revealed. A single dash on the whole <text> would draw every letter at once.',
|
|
34
|
+
'Fill waits for the stroke: the transition on fill is delayed by the draw duration, so ink arrives after the pen.',
|
|
35
|
+
'paint-order: stroke keeps the stroke under the fill, so the finished letter is the plain glyph with no bold outline.',
|
|
36
|
+
'The <svg> is sized by its viewBox and fills the container width; nothing is measured on the client and the text scales like a headline should.',
|
|
37
|
+
'The line is laid out left to right from x = 0 whatever the page\'s direction: textLength fits it from there, and under dir="rtl" it ran off the left edge with its full stop in front.',
|
|
38
|
+
],
|
|
39
|
+
related: ['split-text', 'typewriter', 'progress'],
|
|
40
|
+
intents: ['text stroke animation', 'draw text outline svg', 'handwritten headline animation', 'svg text stroke dashoffset', 'outline text draw on scroll'],
|
|
41
|
+
replaces: ['TextPressure / stroke text effects (React Bits)', 'SVG stroke text with GSAP DrawSVG'],
|
|
42
|
+
fixture: { props: { text: 'Fixture content for StrokeDraw' }, expect: 'ma-sr">Fixture content for StrokeDraw</span>' },
|
|
43
|
+
} satisfies ComponentMeta;
|