@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,39 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'CountdownFlip',
|
|
5
|
+
slug: 'countdown-flip',
|
|
6
|
+
title: 'Countdown Flip',
|
|
7
|
+
description:
|
|
8
|
+
'A flip clock counting down to a date: days, hours, minutes, seconds, each a card whose top half folds down over the hinge when the number changes. The only script is one timer a second, set to the moment the next second begins, that writes the digits and stops while the tab is hidden; the flip is CSS. The server renders the time left at build for readers without JavaScript, or the done state when the target has already passed.',
|
|
9
|
+
summary: 'A flip clock that counts down to a date in days, hours, minutes and seconds, each card folding over its hinge.',
|
|
10
|
+
category: 'data',
|
|
11
|
+
tier: 'C',
|
|
12
|
+
trigger: ['load'],
|
|
13
|
+
support: { baseline: 'widely', needs: ['clip-path'] },
|
|
14
|
+
readout: '<CountdownFlip to={launch} done="Launched.">',
|
|
15
|
+
usage: `---\n// next 1 January, 00:00 UTC: relative to the build, so the example never runs out. A real launch is an ISO date with an\n// offset (or a timestamp) from your config or CMS: the build prints the time left and the browser keeps it current\nconst launch = Date.UTC(new Date().getUTCFullYear() + 1, 0, 1);\n---\n<CountdownFlip to={launch} class="text-4xl" done="Launched." />\n<CountdownFlip to={launch} labels={{ days: ['Tag', 'Tage'], hours: ['Stunde', 'Stunden'], minutes: ['Minute', 'Minuten'], seconds: ['Sekunde', 'Sekunden'] }} done="Gestartet." />\n\n<!-- a short timer: without a days card, hours wrap at 24 -->\n<CountdownFlip to={Date.now() + 90 * 60 * 1000} units={['hours', 'minutes', 'seconds']} labels={{ hours: 'h', minutes: 'm', seconds: 's' }} />`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'to', type: 'string | number', description: 'Target: an ISO date string or a timestamp in ms.' },
|
|
18
|
+
{ name: 'as', type: 'HTMLTag', default: "'div'", description: 'Element to render.' },
|
|
19
|
+
{ name: 'units', type: "('days' | 'hours' | 'minutes' | 'seconds')[]", default: 'all four', description: 'Units to show, in order.' },
|
|
20
|
+
{ name: 'labels', type: 'Partial<Record<Unit, string | [one: string, other: string]>>', description: 'Labels under the cards; default the unit names. A [one, other] pair gives the spoken name its singular ("1 day", "2 days"); the card always shows `other`, and `one` is used when the number is exactly 1. A plain string is used for both.' },
|
|
21
|
+
{ name: 'done', type: 'string', description: 'Text shown once the countdown reaches zero, and the timer\'s accessible name from then on. When the target has passed before the build, the server renders it (no script needed).' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'Digits change in place; no card folds. Read live, so the site\'s motion switch applies on the next tick.',
|
|
24
|
+
clientRouter: 'Binds through the shared runtime: the timer is cleared before the swap and started on the new page.',
|
|
25
|
+
craft: [
|
|
26
|
+
'Time is the one thing CSS cannot know, so the script is exactly that: read the clock, write four numbers, once a second. Everything visible is a CSS animation on a data attribute.',
|
|
27
|
+
'A flip clock is two half-turns, not one: the old top folds down on an ease-in (gravity), then the new bottom unfolds on an ease-out (landing). Each takes half the preset duration.',
|
|
28
|
+
'Four layers per card and no cloning: the falling flap and the bottom half carry the old number, the top half and the rising flap carry the new one. The card is a translucent tint (--ma-edge), so a covered layer would show through. Each half shows one layer at a time: the new top waits under the falling flap until it is edge-on, the old bottom goes as the new one unfolds, and the unfolded flap stays the bottom half. Before the first flip the flaps are hidden.',
|
|
29
|
+
'The timer stops when the tab is hidden and catches up when it returns, because nobody sees a countdown in a background tab.',
|
|
30
|
+
'Each tick is a timeout to the next whole second left, with a 20 ms margin. A flat 1000 ms interval keeps the phase of the moment it started, so the card turns up to a second late, and a phase that sits on a boundary lets timer jitter skip a second or show one twice.',
|
|
31
|
+
'role="timer" with a spoken label the script keeps current ("1 day, 2 hours", singular from the `one` form at one extra comparison a second); the cards and the done text are aria-hidden, and at zero the label becomes the done text.',
|
|
32
|
+
'When the countdown finishes, the cards step back to `--ma-dim`, the level secondary text uses, and the labels stay where they were. Decoration may go fainter than that; digits may not.',
|
|
33
|
+
'Cards are 1.75em wide for two digits and grow with their padded digits past 99 days, so a third digit widens the card instead of spilling over its edges.',
|
|
34
|
+
],
|
|
35
|
+
related: ['split-flap', 'number-flow', 'count-up'],
|
|
36
|
+
intents: ['countdown flip clock', 'flip clock countdown', 'launch countdown timer', 'countdown to date animation', 'flip counter css'],
|
|
37
|
+
replaces: ['CountdownTimer (Hover.dev)', 'flip clock libraries (FlipClock.js, flipdown)'],
|
|
38
|
+
fixture: { props: { to: '2099-01-01T00:00:00Z' }, expect: 'class="ma-cd"' },
|
|
39
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CounterPreloader — a count from 0 to 100 and a curtain that lifts, zero
|
|
4
|
+
* JS. A fixed layer that never stands in the way: `pointer-events: none`
|
|
5
|
+
* from the first frame, `aria-hidden`, and the page under it is rendered
|
|
6
|
+
* and usable throughout. The number is one registered integer animated
|
|
7
|
+
* 0 → 100 (literal keyframes) printed through `counter-set`; the bar reads
|
|
8
|
+
* the same integer; then the layer translates off the top and ends
|
|
9
|
+
* hidden. The whole thing lasts twice the slow token plus one duration —
|
|
10
|
+
* about two seconds — and is not configurable longer: a preloader is a
|
|
11
|
+
* promise, not a stage. `once` inlines 187 B that skip it for the rest of
|
|
12
|
+
* the session (sessionStorage), before paint, on a full load and after
|
|
13
|
+
* every ClientRouter swap (`data-astro-rerun`) — `transition:persist` was
|
|
14
|
+
* tried and does not do it: the router moves the node into the new page
|
|
15
|
+
* and a moved node restarts its CSS animations (measured, docs/phase-7e.md).
|
|
16
|
+
* Under reduced motion, and in an engine without @property, it never shows.
|
|
17
|
+
*/
|
|
18
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
21
|
+
/** Show once per session: an inline script, measured from its source string. */
|
|
22
|
+
once?: boolean;
|
|
23
|
+
/** Layer colour; default the ink token. */
|
|
24
|
+
color?: string;
|
|
25
|
+
/** Count colour; default the panel token. */
|
|
26
|
+
textColor?: string;
|
|
27
|
+
/** Element id and the once key. */
|
|
28
|
+
id?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { once = false, color, textColor, id = 'ma-preloader', class: className, style, ...rest } = Astro.props;
|
|
32
|
+
const vars = [color && `--ma-preloader-bg:${color}`, textColor && `--ma-preloader-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
33
|
+
/** Runs where it is rendered, before the next paint: a session that has seen the layer removes it; the first sight is recorded. */
|
|
34
|
+
const ONCE = `(function(){var s=document.currentScript,l=s.previousElementSibling,K='ma-preloader:'+l.id;try{if(sessionStorage.getItem(K)){l.remove();return}sessionStorage.setItem(K,'1')}catch(e){}})()`;
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<div class:list={['ma-preloader', className]} id={id} aria-hidden="true" style={vars || undefined} {...rest}>
|
|
38
|
+
<span class="ma-preloader__bar"></span>
|
|
39
|
+
<span class="ma-preloader__count"><span class="ma-preloader__n"></span><span class="ma-preloader__unit">%</span></span>
|
|
40
|
+
</div>
|
|
41
|
+
{once && <script is:inline data-astro-rerun set:html={ONCE} />}
|
|
42
|
+
|
|
43
|
+
<style is:global>
|
|
44
|
+
@property --ma-preloader-n {
|
|
45
|
+
syntax: '<integer>';
|
|
46
|
+
inherits: true;
|
|
47
|
+
initial-value: 0;
|
|
48
|
+
}
|
|
49
|
+
/* the support probe: registered, never set — visible where @property exists, the fallback everywhere else */
|
|
50
|
+
@property --ma-preloader-ok {
|
|
51
|
+
syntax: 'visible';
|
|
52
|
+
inherits: false;
|
|
53
|
+
initial-value: visible;
|
|
54
|
+
}
|
|
55
|
+
@keyframes ma-preloader-count {
|
|
56
|
+
from {
|
|
57
|
+
--ma-preloader-n: 0;
|
|
58
|
+
}
|
|
59
|
+
to {
|
|
60
|
+
--ma-preloader-n: 100;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
@keyframes ma-preloader-lift {
|
|
64
|
+
to {
|
|
65
|
+
translate: 0 -100%;
|
|
66
|
+
visibility: hidden;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
@layer components {
|
|
70
|
+
:where(.ma-preloader) {
|
|
71
|
+
/* the count runs twice the slow token, then the lift takes one duration */
|
|
72
|
+
--ma-preloader-count: calc(var(--ma-duration-slow) * 2);
|
|
73
|
+
position: fixed;
|
|
74
|
+
inset: 0;
|
|
75
|
+
z-index: 9999;
|
|
76
|
+
display: grid;
|
|
77
|
+
align-content: end;
|
|
78
|
+
padding: clamp(1rem, 4vw, 3rem);
|
|
79
|
+
background: var(--ma-preloader-bg, var(--ma-ink));
|
|
80
|
+
color: var(--ma-preloader-fg, var(--ma-panel));
|
|
81
|
+
pointer-events: none;
|
|
82
|
+
visibility: var(--ma-preloader-ok, hidden);
|
|
83
|
+
animation:
|
|
84
|
+
ma-preloader-count var(--ma-preloader-count) var(--ma-ease-out) forwards,
|
|
85
|
+
ma-preloader-lift var(--ma-duration) var(--ma-ease-in-out) var(--ma-preloader-count) forwards;
|
|
86
|
+
}
|
|
87
|
+
:where(.ma-preloader__bar) {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: auto 0 0 0;
|
|
90
|
+
block-size: 3px;
|
|
91
|
+
background: currentColor;
|
|
92
|
+
transform-origin: 0 50%;
|
|
93
|
+
scale: calc(var(--ma-preloader-n) / 100) 1;
|
|
94
|
+
opacity: 0.6;
|
|
95
|
+
}
|
|
96
|
+
:where(.ma-preloader__count) {
|
|
97
|
+
justify-self: end;
|
|
98
|
+
font-size: clamp(3rem, 12vw, 9rem);
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
line-height: 1;
|
|
101
|
+
letter-spacing: -0.04em;
|
|
102
|
+
font-variant-numeric: tabular-nums;
|
|
103
|
+
}
|
|
104
|
+
:where(.ma-preloader__n) {
|
|
105
|
+
counter-set: ma-preloader var(--ma-preloader-n);
|
|
106
|
+
}
|
|
107
|
+
:where(.ma-preloader__n)::before {
|
|
108
|
+
content: counter(ma-preloader);
|
|
109
|
+
}
|
|
110
|
+
:where(.ma-preloader__unit) {
|
|
111
|
+
font-size: 0.4em;
|
|
112
|
+
vertical-align: 0.9em;
|
|
113
|
+
opacity: var(--ma-dim, 0.7);
|
|
114
|
+
}
|
|
115
|
+
@media (prefers-reduced-motion: reduce) {
|
|
116
|
+
:where(.ma-preloader) {
|
|
117
|
+
visibility: hidden;
|
|
118
|
+
animation: none;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'CounterPreloader',
|
|
5
|
+
slug: 'counter-preloader',
|
|
6
|
+
title: 'Counter Preloader',
|
|
7
|
+
description: 'A preloader that counts from 0 to 100 and then lifts like a curtain, on a fixed layer that never blocks the page: pointer-events none from the first frame, aria-hidden, the page rendered and usable beneath. The number is one registered integer printed through counter-set; the layer then translates off and ends hidden. It runs about two seconds, set by the tokens. once skips it for the rest of the session, on a reload and after every ClientRouter swap. Under reduced motion, or without @property, it never shows. Zero JavaScript.',
|
|
8
|
+
summary: 'A preloader that counts from 0 to 100 and lifts like a curtain, without ever blocking the page beneath it.',
|
|
9
|
+
category: 'loading',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['load'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['property', 'counter-set'], fallback: 'without @property the layer is never visible (the registered probe that reveals it does not exist) and the page shows at once' },
|
|
13
|
+
readout: '<CounterPreloader once>',
|
|
14
|
+
usage: `---\n// in your layout, first thing in <body>\n---\n<CounterPreloader once />\n\n<!-- a colour of your own, and a key per site -->\n<CounterPreloader id="studio" color="oklch(0.2 0.02 60)" textColor="oklch(0.95 0.02 80)" />`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'once', type: 'boolean', default: 'false', description: 'Show once per session: an inline script of 187 B raw (measured from its source string) removes the layer before paint when sessionStorage remembers it: on a full load and, with data-astro-rerun, after every ClientRouter swap.' },
|
|
17
|
+
{ name: 'color', type: 'string', description: 'Layer colour; default the ink token.' },
|
|
18
|
+
{ name: 'textColor', type: 'string', description: 'Count colour; default the panel token.' },
|
|
19
|
+
{ name: 'id', type: 'string', default: "'ma-preloader'", description: 'Element id and the once key.' },
|
|
20
|
+
],
|
|
21
|
+
reducedMotion: 'Never shown.',
|
|
22
|
+
clientRouter: 'Without once every page renders the layer again and it counts again, like any CSS animation. With once the inline script carries data-astro-rerun, runs after every swap and removes the layer before paint. transition:persist was measured and does not help: the router moves the kept node into the new page, and a moved node restarts its CSS animations.',
|
|
23
|
+
craft: [
|
|
24
|
+
'A preloader must never gate the page. The layer has pointer-events: none from its first frame and aria-hidden, the content beneath is rendered and focusable, and the whole run is bounded by the tokens: twice the slow duration for the count plus one duration for the lift, about two seconds. There is no duration prop on purpose.',
|
|
25
|
+
'The number is one registered <integer> animated 0 → 100 with literal keyframes (Firefox does not interpolate a var() in a keyframe) and printed through counter-set, so it steps like a counter and the bar can read the same integer for its scale.',
|
|
26
|
+
'The lift is a translate keyframe delayed by the count\'s duration, with visibility: hidden at its end and fill-mode forwards, so the layer is truly gone, not an invisible box over the page.',
|
|
27
|
+
'Support is probed without a script: a registered custom property with initial-value visible that nothing ever sets. Where @property exists, visibility: var(--ma-preloader-ok, hidden) resolves to visible; where it does not, the fallback keeps the layer hidden. No flash of a stuck curtain in an old engine.',
|
|
28
|
+
'once is inline and measured from its source string, like StickyBanner\'s remember: it has to run before the first paint of the page it is on, and it carries data-astro-rerun so the router runs it again after a swap. transition:persist was the plan; measured, the persisted node restarts its count on the next page, because moving a node restarts its CSS animations.',
|
|
29
|
+
],
|
|
30
|
+
related: ['progress', 'curtain', 'count-up'],
|
|
31
|
+
intents: ['preloader counter', 'page loader 0 to 100', 'loading screen counter', 'intro curtain', 'site preloader css', 'studio loading number'],
|
|
32
|
+
replaces: ['Preloader (Osmo)', 'Loader (Aceternity)', 'Studio-style loading count'],
|
|
33
|
+
fixture: { props: { id: 'ma-fixture-preloader' }, slot: '', expect: 'ma-preloader__count' },
|
|
34
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Cursor — a custom pointer that trails the real one and reacts to what it
|
|
4
|
+
* is over: a dot, a ring that grows on links and buttons, or a label
|
|
5
|
+
* ("View") that any element can ask for with data-cursor-label.
|
|
6
|
+
*
|
|
7
|
+
* One fixed element. The script writes the pointer position as two custom
|
|
8
|
+
* properties and one state attribute; the lag, the growth and the label
|
|
9
|
+
* morph are CSS transitions on the preset's curve, so the follow has the
|
|
10
|
+
* same character as everything else on the page and no lerp runs in JS.
|
|
11
|
+
* Targets are delegated: one pointerover listener on the document reads
|
|
12
|
+
* `a, button, [data-cursor]`, never a listener per element. Not installed on
|
|
13
|
+
* touch, coarse pointers or reduced motion; hidden over text fields so the
|
|
14
|
+
* native I-beam does its job; gone when the pointer leaves the window.
|
|
15
|
+
* `within` confines it to the parent element (a section, a card, a demo);
|
|
16
|
+
* hideNative is a :has() rule on the state, so the script never touches <html>.
|
|
17
|
+
*/
|
|
18
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
21
|
+
/** dot: a small disc · ring: a disc that grows over targets · label: a disc that becomes a pill with text over labelled targets (and grows over the rest). */
|
|
22
|
+
mode?: 'dot' | 'ring' | 'label';
|
|
23
|
+
/** Disc diameter in px. */
|
|
24
|
+
size?: number;
|
|
25
|
+
/** Growth over targets, as a multiple of size. */
|
|
26
|
+
grow?: number;
|
|
27
|
+
/** mix-blend-mode: difference — inverts what it covers. Reads badly on a light theme unless the disc is dark. */
|
|
28
|
+
blend?: boolean;
|
|
29
|
+
/** Hide the native cursor everywhere except over text fields. */
|
|
30
|
+
hideNative?: boolean;
|
|
31
|
+
/** Confine to the parent element: positioned absolutely inside it, pointer read from it only. */
|
|
32
|
+
within?: boolean;
|
|
33
|
+
/** Default label text for mode="label" when a target has data-cursor-label without a value. */
|
|
34
|
+
label?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { mode = 'ring', size = 12, grow = 3, blend = false, hideNative = false, within = false, label = 'View', class: className, style, ...rest } = Astro.props;
|
|
38
|
+
const inline = [`--ma-cursor-size:${size}px`, `--ma-cursor-grow:${grow}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<div
|
|
42
|
+
class:list={['ma-cursor', className]}
|
|
43
|
+
data-ma-cursor
|
|
44
|
+
data-mode={mode}
|
|
45
|
+
data-state="hidden"
|
|
46
|
+
data-blend={blend ? '' : undefined}
|
|
47
|
+
data-hide-native={hideNative ? '' : undefined}
|
|
48
|
+
data-within={within ? '' : undefined}
|
|
49
|
+
data-label={label}
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
style={inline}
|
|
52
|
+
{...rest}
|
|
53
|
+
><span class="ma-cursor__label"></span></div>
|
|
54
|
+
|
|
55
|
+
<style is:global>
|
|
56
|
+
@layer components {
|
|
57
|
+
:where(.ma-cursor) {
|
|
58
|
+
position: fixed;
|
|
59
|
+
left: 0;
|
|
60
|
+
top: 0;
|
|
61
|
+
z-index: 2147483000;
|
|
62
|
+
display: grid;
|
|
63
|
+
place-items: center;
|
|
64
|
+
box-sizing: border-box;
|
|
65
|
+
width: var(--ma-cursor-size, 12px);
|
|
66
|
+
height: var(--ma-cursor-size, 12px);
|
|
67
|
+
border-radius: 999px;
|
|
68
|
+
background: var(--ma-ink);
|
|
69
|
+
color: var(--ma-panel);
|
|
70
|
+
pointer-events: none;
|
|
71
|
+
/* --ma-cursor-x / --ma-cursor-y: the pointer, written by the script; the disc is centred on it */
|
|
72
|
+
translate: calc(var(--ma-cursor-x, -100px) - 50%) calc(var(--ma-cursor-y, -100px) - 50%);
|
|
73
|
+
scale: 1;
|
|
74
|
+
opacity: 1;
|
|
75
|
+
/* the lag is the fast token on the exit curve: close enough to feel attached, late enough to read as a trail */
|
|
76
|
+
transition:
|
|
77
|
+
translate var(--ma-duration-fast) var(--ma-ease-out),
|
|
78
|
+
scale var(--ma-duration) var(--ma-ease),
|
|
79
|
+
width var(--ma-duration) var(--ma-ease),
|
|
80
|
+
height var(--ma-duration) var(--ma-ease),
|
|
81
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out),
|
|
82
|
+
border-radius var(--ma-duration) var(--ma-ease);
|
|
83
|
+
}
|
|
84
|
+
:where(.ma-cursor[data-within]) {
|
|
85
|
+
position: absolute;
|
|
86
|
+
z-index: 10;
|
|
87
|
+
}
|
|
88
|
+
:where(.ma-cursor[data-state='hidden']) {
|
|
89
|
+
opacity: 0;
|
|
90
|
+
scale: 0.5;
|
|
91
|
+
}
|
|
92
|
+
/* ring and label grow over links, buttons and anything with data-cursor */
|
|
93
|
+
:where(.ma-cursor[data-mode='ring'][data-state='hover']),
|
|
94
|
+
:where(.ma-cursor[data-mode='label'][data-state='hover']) {
|
|
95
|
+
scale: var(--ma-cursor-grow, 3);
|
|
96
|
+
opacity: 0.35;
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-cursor[data-mode='dot'][data-state='hover']) {
|
|
99
|
+
scale: 1.6;
|
|
100
|
+
}
|
|
101
|
+
/* the label: the disc widens into a pill; the text fades in after the pill has room */
|
|
102
|
+
:where(.ma-cursor__label) {
|
|
103
|
+
display: block;
|
|
104
|
+
padding: 0 0.9em;
|
|
105
|
+
font: 500 0.75rem/1 var(--font-sans, system-ui, sans-serif);
|
|
106
|
+
letter-spacing: 0.02em;
|
|
107
|
+
white-space: nowrap;
|
|
108
|
+
opacity: 0;
|
|
109
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
110
|
+
}
|
|
111
|
+
:where(.ma-cursor[data-state='label']) {
|
|
112
|
+
width: auto;
|
|
113
|
+
height: calc(var(--ma-cursor-size, 12px) * 2.4);
|
|
114
|
+
min-width: calc(var(--ma-cursor-size, 12px) * 2.4);
|
|
115
|
+
opacity: 1;
|
|
116
|
+
scale: 1;
|
|
117
|
+
}
|
|
118
|
+
:where(.ma-cursor[data-state='label'] .ma-cursor__label) {
|
|
119
|
+
opacity: 1;
|
|
120
|
+
transition-delay: var(--ma-duration-fast);
|
|
121
|
+
}
|
|
122
|
+
:where(.ma-cursor[data-blend]) {
|
|
123
|
+
mix-blend-mode: difference;
|
|
124
|
+
background: #fff;
|
|
125
|
+
color: #000;
|
|
126
|
+
}
|
|
127
|
+
:where(.ma-cursor[data-state='down']) {
|
|
128
|
+
scale: 0.7;
|
|
129
|
+
}
|
|
130
|
+
/* hideNative: while the custom cursor is live, the native one is off everywhere but text fields — CSS reads the state, no script.
|
|
131
|
+
Only where the disc can show: reduced motion switched on after the script bound (or the page's own switch) hides the disc
|
|
132
|
+
below and gives the native pointer back here, instead of leaving no pointer at all */
|
|
133
|
+
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
|
|
134
|
+
:where(html:not([data-ma-motion='reduce']):has(.ma-cursor[data-hide-native]:not([data-state='hidden']))) :where(*:not(input, textarea, select, [contenteditable=''], [contenteditable='true'])) {
|
|
135
|
+
cursor: none;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/* touch, coarse pointers and reduced motion: never shown — the script does not bind either */
|
|
139
|
+
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
|
|
140
|
+
:where(.ma-cursor) {
|
|
141
|
+
display: none;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
</style>
|
|
146
|
+
|
|
147
|
+
<script>
|
|
148
|
+
import { onMount } from '../lib/runtime';
|
|
149
|
+
|
|
150
|
+
onMount<HTMLElement>('[data-ma-cursor]', (el, { signal, reducedMotion }) => {
|
|
151
|
+
if (reducedMotion || !matchMedia('(hover:hover) and (pointer:fine)').matches) return;
|
|
152
|
+
const within = el.hasAttribute('data-within');
|
|
153
|
+
const host = within ? el.parentElement! : document;
|
|
154
|
+
let over = 'idle';
|
|
155
|
+
const state = (v: string) => (el.dataset.state = v);
|
|
156
|
+
const on = (type: string, fn: (e: PointerEvent) => void) => host.addEventListener(type, fn as EventListener, { signal, passive: true });
|
|
157
|
+
on('pointermove', (e) => {
|
|
158
|
+
const r = within ? (host as HTMLElement).getBoundingClientRect() : { left: 0, top: 0 };
|
|
159
|
+
el.style.setProperty('--ma-cursor-x', `${e.clientX - r.left}px`);
|
|
160
|
+
el.style.setProperty('--ma-cursor-y', `${e.clientY - r.top}px`);
|
|
161
|
+
if (el.dataset.state !== 'down') state(over);
|
|
162
|
+
});
|
|
163
|
+
// one delegated listener: links, buttons and data-cursor targets grow the ring; text fields hide it; data-cursor-label names it
|
|
164
|
+
on('pointerover', (e) => {
|
|
165
|
+
const g = e.target as HTMLElement;
|
|
166
|
+
const t = g.closest('a,button,[data-cursor]');
|
|
167
|
+
const text = t?.getAttribute('data-cursor-label');
|
|
168
|
+
over = g.matches('input,select,textarea,option') || g.isContentEditable ? 'hidden' : !t ? 'idle' : el.dataset.mode === 'label' && text != null ? ((el.firstChild!.textContent = text || el.dataset.label!), 'label') : 'hover';
|
|
169
|
+
state(over);
|
|
170
|
+
});
|
|
171
|
+
on('pointerdown', () => state('down'));
|
|
172
|
+
on('pointerup', () => state(over));
|
|
173
|
+
// out of the window (or the host): nothing under the pointer is ours
|
|
174
|
+
on('pointerout', (e) => (within ? !(host as Node).contains(e.relatedTarget as Node) : !e.relatedTarget) && state('hidden'));
|
|
175
|
+
signal.addEventListener('abort', () => state('hidden'));
|
|
176
|
+
});
|
|
177
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Cursor',
|
|
5
|
+
slug: 'cursor',
|
|
6
|
+
title: 'Cursor',
|
|
7
|
+
description: 'A custom pointer that trails the real one: a dot, a ring that grows over links and buttons, or a label ("View") any element can ask for with data-cursor-label. One fixed element; the script writes the position and a state, CSS transitions the lag, the growth and the pill on the preset curve. Not installed on touch or under reduced motion.',
|
|
8
|
+
summary: 'A custom cursor that trails the pointer: a dot, a ring that grows over links, or a label such as View.',
|
|
9
|
+
category: 'pointer',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['pointer'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<Cursor mode="label">',
|
|
14
|
+
usage: `<Cursor mode="label" />\n<a href="/work/one" data-cursor-label="View">\n <img src="/one.jpg" alt="" />\n</a>\n\n<!-- inside one section only -->\n<section class="relative">\n <Cursor mode="ring" within />\n …\n</section>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'mode', type: "'dot' | 'ring' | 'label'", default: "'ring'", description: 'dot: a small disc · ring: grows over targets · label: becomes a pill with text over targets that carry data-cursor-label, grows over the rest.' },
|
|
17
|
+
{ name: 'size', type: 'number', default: '12', description: 'Disc diameter in px.' },
|
|
18
|
+
{ name: 'grow', type: 'number', default: '3', description: 'Growth over targets, as a multiple of size.' },
|
|
19
|
+
{ name: 'blend', type: 'boolean', default: 'false', description: 'mix-blend-mode: difference, so the disc inverts what it covers. Off by default: on a light theme a light disc disappears.' },
|
|
20
|
+
{ name: 'hideNative', type: 'boolean', default: 'false', description: 'Hide the native cursor while the custom one is live, everywhere except over text fields.' },
|
|
21
|
+
{ name: 'within', type: 'boolean', default: 'false', description: 'Confine to the parent element: positioned inside it, pointer read from it only, hidden when the pointer leaves it.' },
|
|
22
|
+
{ name: 'label', type: 'string', default: "'View'", description: 'Text for targets that carry data-cursor-label with no value.' },
|
|
23
|
+
],
|
|
24
|
+
reducedMotion: 'Not installed: the element is hidden and the script never binds, so the native cursor is the only one. Switched on while the page is open (or through the page\'s own motion switch), the disc hides and hideNative gives the native cursor back.',
|
|
25
|
+
clientRouter: 'Bound through the shared runtime: the listeners are aborted before the swap and the cursor returns to its hidden state, which ends hideNative (a :has() rule on that state, nothing written to <html>); the next page binds its own.',
|
|
26
|
+
craft: [
|
|
27
|
+
'The lag is a CSS transition on translate, on the fast token and the exit curve: close enough to feel attached, late enough to read as a trail. No lerp runs in JavaScript, so the follow costs one style write per move.',
|
|
28
|
+
'Targets are found by one pointerover listener on the document (or the host) through closest(), never a listener per element, so a page with three hundred links pays nothing per link.',
|
|
29
|
+
'Over input, textarea, select and contenteditable the custom cursor hides: the I-beam is information the ring would cover.',
|
|
30
|
+
'Never installed where (hover: hover) and (pointer: fine) do not both hold, and never under reduced motion. On a phone, a custom cursor would be a stuck dot.',
|
|
31
|
+
'pointerout with no relatedTarget means the pointer left the window; the cursor hides rather than freezing at the edge.',
|
|
32
|
+
],
|
|
33
|
+
related: ['magnetic', 'image-trail', 'spotlight'],
|
|
34
|
+
intents: ['custom cursor', 'cursor follower', 'cursor that says view over images', 'mouse follower ring', 'blend mode cursor', 'custom mouse pointer astro'],
|
|
35
|
+
replaces: ['SplashCursor / BlobCursor (React Bits)', 'FollowerPointerCard (Aceternity)', 'Cursor (Motion Primitives)', 'SmoothCursor (Magic UI)'],
|
|
36
|
+
fixture: { props: { mode: 'label' }, expect: 'class="ma-cursor"' },
|
|
37
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CursorGrid — a field of cells that light where the cursor is and fade where
|
|
4
|
+
* it was. Zero JS: every cell is an element with its own :hover, lit
|
|
5
|
+
* instantly and released on a slow transition, so the trail is the browser's
|
|
6
|
+
* own hover state decaying. An idle sweep keeps the field alive when nothing
|
|
7
|
+
* touches it. Fills its positioned parent; put content above it.
|
|
8
|
+
*
|
|
9
|
+
* The cost is in the HTML, not the script: cols × rows empty elements
|
|
10
|
+
* (32 × 16 = 512 by default, ~5 kB of markup, ~120 B gzipped).
|
|
11
|
+
*/
|
|
12
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
13
|
+
|
|
14
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
15
|
+
/** Columns. */
|
|
16
|
+
cols?: number;
|
|
17
|
+
/** Rows. */
|
|
18
|
+
rows?: number;
|
|
19
|
+
/** Colour of a lit cell; any CSS colour. */
|
|
20
|
+
color?: string;
|
|
21
|
+
/** How long a cell takes to go dark after the cursor leaves it, ms. */
|
|
22
|
+
fade?: number;
|
|
23
|
+
/** Slow idle sweep across the field when nothing is hovered. */
|
|
24
|
+
idle?: boolean;
|
|
25
|
+
/** Show the hairline grid between cells. */
|
|
26
|
+
lines?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { cols = 32, rows = 16, color = 'currentColor', fade = 900, idle = true, lines = true, class: className, style, ...rest } = Astro.props;
|
|
30
|
+
const count = Math.max(1, Math.min(cols, 96)) * Math.max(1, Math.min(rows, 64));
|
|
31
|
+
const vars = [`--ma-cg-cols:${cols}`, `--ma-cg-rows:${rows}`, `--ma-cg-color:${color}`, `--ma-cg-fade:${fade}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<div
|
|
35
|
+
class:list={['ma-cursor-grid', className]}
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
data-idle={idle ? '' : undefined}
|
|
38
|
+
data-lines={lines ? '' : undefined}
|
|
39
|
+
style={vars}
|
|
40
|
+
{...rest}
|
|
41
|
+
>
|
|
42
|
+
{Array.from({ length: count }, () => <i></i>)}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<style is:global>
|
|
46
|
+
@layer components {
|
|
47
|
+
:where(.ma-cursor-grid) {
|
|
48
|
+
position: absolute;
|
|
49
|
+
inset: 0;
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: repeat(var(--ma-cg-cols, 32), 1fr);
|
|
52
|
+
grid-template-rows: repeat(var(--ma-cg-rows, 16), 1fr);
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
contain: strict;
|
|
55
|
+
}
|
|
56
|
+
:where(.ma-cursor-grid[data-lines]) {
|
|
57
|
+
background-image:
|
|
58
|
+
linear-gradient(to right, var(--ma-edge) 1px, transparent 1px),
|
|
59
|
+
linear-gradient(to bottom, var(--ma-edge) 1px, transparent 1px);
|
|
60
|
+
background-size: calc(100% / var(--ma-cg-cols, 32)) calc(100% / var(--ma-cg-rows, 16));
|
|
61
|
+
}
|
|
62
|
+
:where(.ma-cursor-grid > i) {
|
|
63
|
+
display: block;
|
|
64
|
+
background: var(--ma-cg-color, currentColor);
|
|
65
|
+
opacity: 0;
|
|
66
|
+
transition: opacity var(--ma-cg-fade, 900ms) var(--ma-ease-out);
|
|
67
|
+
}
|
|
68
|
+
/* lit at once, released slowly — the trail is the hover state decaying */
|
|
69
|
+
:where(.ma-cursor-grid > i:hover) {
|
|
70
|
+
opacity: 0.9;
|
|
71
|
+
transition-duration: 0ms;
|
|
72
|
+
}
|
|
73
|
+
:where(.ma-cursor-grid > i:hover + i),
|
|
74
|
+
:where(.ma-cursor-grid > i:has(+ i:hover)) {
|
|
75
|
+
opacity: 0.35;
|
|
76
|
+
transition-duration: 60ms;
|
|
77
|
+
}
|
|
78
|
+
/* idle sweep: a soft band drifting across the field, paused while the cursor is in it */
|
|
79
|
+
:where(.ma-cursor-grid[data-idle])::after {
|
|
80
|
+
content: '';
|
|
81
|
+
position: absolute;
|
|
82
|
+
inset: -20% -60%;
|
|
83
|
+
background: linear-gradient(100deg, transparent 40%, var(--ma-cg-color, currentColor) 50%, transparent 60%);
|
|
84
|
+
opacity: 0.12;
|
|
85
|
+
will-change: translate;
|
|
86
|
+
animation: ma-cursor-grid-sweep 9s var(--ma-ease-in-out) infinite alternate;
|
|
87
|
+
pointer-events: none;
|
|
88
|
+
}
|
|
89
|
+
:where(.ma-cursor-grid[data-idle]:hover)::after {
|
|
90
|
+
animation-play-state: paused;
|
|
91
|
+
opacity: 0;
|
|
92
|
+
transition: opacity 600ms;
|
|
93
|
+
}
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
:where(.ma-cursor-grid[data-idle])::after {
|
|
96
|
+
animation: none;
|
|
97
|
+
opacity: 0;
|
|
98
|
+
}
|
|
99
|
+
:where(.ma-cursor-grid > i) {
|
|
100
|
+
transition-duration: 200ms;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@keyframes ma-cursor-grid-sweep {
|
|
106
|
+
from {
|
|
107
|
+
translate: -35% 0;
|
|
108
|
+
}
|
|
109
|
+
to {
|
|
110
|
+
translate: 35% 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'CursorGrid',
|
|
5
|
+
slug: 'cursor-grid',
|
|
6
|
+
title: 'Cursor Grid',
|
|
7
|
+
description:
|
|
8
|
+
'A field of cells that light up under the cursor and fade where it has been, with a slow idle sweep when nothing is touched. Zero JavaScript: every cell is an element whose own hover state decays on a transition, so the trail is the browser doing the work. Fills its positioned parent.',
|
|
9
|
+
summary: 'A grid of cells that light up under the cursor and fade behind it, with a slow sweep when nothing moves.',
|
|
10
|
+
category: 'pointer',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['pointer', 'always'],
|
|
13
|
+
support: { baseline: 'widely', needs: ['has'] },
|
|
14
|
+
readout: '<CursorGrid cols={32} rows={16}>',
|
|
15
|
+
usage: `<section class="relative min-h-[60vh]">\n <CursorGrid color="oklch(78% 0.16 60)" />\n <h1 class="relative">Move your cursor. Zero bytes of JavaScript.</h1>\n</section>`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'cols', type: 'number', default: '32', description: 'Columns, max 96. Cells are cols × rows empty elements: the cost is HTML, about 10 B raw a cell.' },
|
|
18
|
+
{ name: 'rows', type: 'number', default: '16', description: 'Rows, max 64.' },
|
|
19
|
+
{ name: 'color', type: 'string', default: "'currentColor'", description: 'Colour of a lit cell.' },
|
|
20
|
+
{ name: 'fade', type: 'number', default: '900', description: 'Milliseconds a cell takes to go dark after the cursor leaves. Longer means a longer trail.' },
|
|
21
|
+
{ name: 'idle', type: 'boolean', default: 'true', description: 'A soft band drifts across the field when nothing is hovered; pauses on hover.' },
|
|
22
|
+
{ name: 'lines', type: 'boolean', default: 'true', description: 'Draw the hairline grid between cells.' },
|
|
23
|
+
],
|
|
24
|
+
reducedMotion: 'The idle sweep stops; cells still light under the cursor but release in 200 ms instead of trailing.',
|
|
25
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
26
|
+
craft: [
|
|
27
|
+
'Lit in 0 ms, released over 900 ms: the asymmetry is what reads as a trail. A symmetric fade reads as lag.',
|
|
28
|
+
'Direct neighbours (previous and next sibling) light to 35% so a fast cursor never leaves single isolated squares.',
|
|
29
|
+
'The idle sweep is a translate on one pseudo-element (compositor only) and pauses the moment the field is hovered so it never fights the cursor.',
|
|
30
|
+
'contain: strict and aria-hidden: 512 elements the layout engine and assistive tech can both ignore.',
|
|
31
|
+
'Content sits above the grid with position: relative; cells under content do not receive hover, which is correct because the cursor is on the text.',
|
|
32
|
+
],
|
|
33
|
+
related: ['glyph-field', 'spotlight', 'grid-pattern', 'aurora'],
|
|
34
|
+
intents: ['cursor grid', 'interactive grid background', 'hover grid cells', 'pointer reactive background', 'hero background reacts to mouse'],
|
|
35
|
+
replaces: ['InteractiveGridPattern (Magic UI)', 'DotGrid (React Bits)'],
|
|
36
|
+
fixture: { props: { cols: 8, rows: 4 }, expect: 'class="ma-cursor-grid"' },
|
|
37
|
+
} satisfies ComponentMeta;
|