@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,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TextScramble — random characters resolve into the text, left to right.
|
|
4
|
+
*
|
|
5
|
+
* The server HTML is the final string. On trigger, a small rAF loop swaps
|
|
6
|
+
* each character for a random one until that character's lock time, then
|
|
7
|
+
* writes the real one. Bound through the runtime; never runs under reduced
|
|
8
|
+
* motion, so the text is simply there.
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
11
|
+
|
|
12
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
13
|
+
/** Final text. */
|
|
14
|
+
text: string;
|
|
15
|
+
/** Element to render. */
|
|
16
|
+
as?: HTMLTag;
|
|
17
|
+
/** Milliseconds between one character locking and the next. */
|
|
18
|
+
speed?: number;
|
|
19
|
+
/** Characters to scramble with. */
|
|
20
|
+
charset?: string;
|
|
21
|
+
/** When to run. */
|
|
22
|
+
trigger?: 'view' | 'load' | 'hover';
|
|
23
|
+
/** Delay before the first lock, ms. */
|
|
24
|
+
delay?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { text, as: Tag = 'span', speed = 30, charset = '!<>-_\\/[]{}—=+*^?#', trigger = 'view', delay = 0, class: className, ...rest } = Astro.props;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<Tag class:list={['ma-scramble', className]} data-ma-scramble data-speed={speed} data-charset={charset} data-trigger={trigger} data-delay={delay} {...rest}>
|
|
31
|
+
<span class="ma-scramble__text" aria-hidden="true">{text}</span>
|
|
32
|
+
<span class="ma-sr">{text}</span>
|
|
33
|
+
</Tag>
|
|
34
|
+
|
|
35
|
+
<style is:global>
|
|
36
|
+
@layer components {
|
|
37
|
+
:where(.ma-scramble__text) {
|
|
38
|
+
font-variant-ligatures: none;
|
|
39
|
+
}
|
|
40
|
+
:where(.ma-scramble[data-running] .ma-scramble__text) {
|
|
41
|
+
/* keep the box stable while glyph widths churn */
|
|
42
|
+
font-variant-numeric: tabular-nums;
|
|
43
|
+
}
|
|
44
|
+
@media (prefers-reduced-motion: reduce) {
|
|
45
|
+
:where(.ma-scramble__text) {
|
|
46
|
+
/* the script never binds; the final text is already here */
|
|
47
|
+
opacity: 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
52
|
+
|
|
53
|
+
<script>
|
|
54
|
+
import { onMount } from '../lib/runtime';
|
|
55
|
+
|
|
56
|
+
onMount<HTMLElement>('[data-ma-scramble]', (el, { signal, reducedMotion }) => {
|
|
57
|
+
if (reducedMotion) return;
|
|
58
|
+
const node = el.querySelector<HTMLElement>('.ma-scramble__text');
|
|
59
|
+
if (!node) return;
|
|
60
|
+
const final = node.textContent ?? '';
|
|
61
|
+
const chars = [...final];
|
|
62
|
+
const speed = Number(el.dataset.speed ?? 30);
|
|
63
|
+
const set = el.dataset.charset ?? '!<>-_\\/[]{}';
|
|
64
|
+
const delay = Number(el.dataset.delay ?? 0);
|
|
65
|
+
let raf = 0;
|
|
66
|
+
let start = 0;
|
|
67
|
+
const frame = (now: number) => {
|
|
68
|
+
let out = '';
|
|
69
|
+
let done = true;
|
|
70
|
+
chars.forEach((c, i) => {
|
|
71
|
+
if (c === ' ' || now >= start + i * speed) out += c;
|
|
72
|
+
else {
|
|
73
|
+
done = false;
|
|
74
|
+
out += set[Math.floor(Math.random() * set.length)];
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
node.textContent = out;
|
|
78
|
+
if (done) delete el.dataset.running;
|
|
79
|
+
else raf = requestAnimationFrame(frame);
|
|
80
|
+
};
|
|
81
|
+
const run = () => {
|
|
82
|
+
cancelAnimationFrame(raf);
|
|
83
|
+
start = performance.now() + delay;
|
|
84
|
+
el.dataset.running = '';
|
|
85
|
+
raf = requestAnimationFrame(frame);
|
|
86
|
+
};
|
|
87
|
+
// Torn down mid-scramble (an element persisted across a swap), it runs to the end at once: every character
|
|
88
|
+
// locked, the real text back. The next bind reads its final string from the node, and read the noise instead.
|
|
89
|
+
signal.addEventListener('abort', () => {
|
|
90
|
+
cancelAnimationFrame(raf);
|
|
91
|
+
frame(Infinity);
|
|
92
|
+
});
|
|
93
|
+
const trigger = el.dataset.trigger;
|
|
94
|
+
if (trigger === 'hover') el.addEventListener('pointerenter', run, { signal });
|
|
95
|
+
else if (trigger === 'load') run();
|
|
96
|
+
else {
|
|
97
|
+
const io = new IntersectionObserver(([e]) => {
|
|
98
|
+
if (e!.isIntersecting) {
|
|
99
|
+
run();
|
|
100
|
+
io.disconnect();
|
|
101
|
+
}
|
|
102
|
+
}, { threshold: 0.4 });
|
|
103
|
+
io.observe(el);
|
|
104
|
+
signal.addEventListener('abort', () => io.disconnect());
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
</script>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'TextScramble',
|
|
5
|
+
slug: 'text-scramble',
|
|
6
|
+
title: 'Text Scramble',
|
|
7
|
+
description: 'Random characters resolve into the real text from left to right when it scrolls into view, on load, or on hover. The server HTML is the final string; a small requestAnimationFrame loop does the rest and never runs under reduced motion.',
|
|
8
|
+
summary: 'A text scramble effect: random characters resolve into the real text on scroll, on load or on hover.',
|
|
9
|
+
category: 'text',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['scroll', 'hover', 'load'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<TextScramble speed={30}>',
|
|
14
|
+
usage: `<TextScramble as="h2" text="Decrypting…" />\n<TextScramble text="hover me" trigger="hover" charset="01" />`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'text', type: 'string', description: 'Final text.' },
|
|
17
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
18
|
+
{ name: 'speed', type: 'number', default: '30', description: 'Milliseconds between one character locking and the next; 20–40 reads as decoding.' },
|
|
19
|
+
{ name: 'charset', type: 'string', default: "'!<>-_\\\\/[]{}—=+*^?#'", description: 'Characters to scramble with.' },
|
|
20
|
+
{ name: 'trigger', type: "'view' | 'load' | 'hover'", default: "'view'", description: 'When to run.' },
|
|
21
|
+
{ name: 'delay', type: 'number', default: '0', description: 'Delay before the first lock, ms.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'The script does not bind; the final text is shown.',
|
|
24
|
+
clientRouter: 'Bound through the shared runtime; the loop is cancelled before a swap and the real text put back, so an element persisted mid-scramble never keeps the noise.',
|
|
25
|
+
craft: [
|
|
26
|
+
'Locks left to right at a fixed interval, so a ten-character word resolves in 300 ms: long enough to read as decoding, short enough not to wait for.',
|
|
27
|
+
'Spaces never scramble; word shapes stay readable throughout.',
|
|
28
|
+
'A visually hidden span (.ma-sr) carries the final text; the churning span is aria-hidden, so assistive tech never hears the noise.',
|
|
29
|
+
'Tabular figures while running so digits do not make the box breathe.',
|
|
30
|
+
],
|
|
31
|
+
related: ['typewriter', 'split-text', 'weight-wave'],
|
|
32
|
+
intents: ['text scramble', 'decrypt text effect', 'hacker text animation', 'scramble letters into words', 'decode text on scroll'],
|
|
33
|
+
replaces: ['DecryptedText (React Bits)', 'HyperText (Magic UI)', 'TextScramble (Aceternity)'],
|
|
34
|
+
fixture: { props: { text: 'Fixture content for TextScramble' }, expect: 'ma-sr">Fixture content for TextScramble</span>' },
|
|
35
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ThemeScript — the anti-flash half of ThemeToggle. Put it in <head>: it
|
|
4
|
+
* stamps the resolved theme on <html> before the first paint, re-applies it
|
|
5
|
+
* after every ClientRouter swap and exposes window.__maTheme for the toggle.
|
|
6
|
+
* One of the few legitimate inline scripts; its size is in lib/theme.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { themeScript } from '../lib/theme';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** localStorage key; the same one ThemeToggle uses. */
|
|
12
|
+
storageKey?: string;
|
|
13
|
+
}
|
|
14
|
+
const { storageKey = 'ma-theme' } = Astro.props;
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<script is:inline set:html={themeScript(storageKey)} />
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ThemeToggle — cycles system → light → dark and the page changes theme
|
|
4
|
+
* through a circle that grows from the click. The choice is stored under
|
|
5
|
+
* ThemeScript's key; ThemeScript in <head> is what applies it before paint
|
|
6
|
+
* and after every swap and exposes window.__maTheme, so this script only
|
|
7
|
+
* advances the choice, writes the origin on <html> and wraps the change in
|
|
8
|
+
* document.startViewTransition. Without ThemeScript, and so without
|
|
9
|
+
* JavaScript, the toggle is hidden: it could only be a dead button.
|
|
10
|
+
* Without view transitions, or under reduced motion, the theme switches
|
|
11
|
+
* at once. The icon is AnimatedIcons (sun ↔ moon) reading data-on; until
|
|
12
|
+
* the script has synced, the icon and the label follow ThemeScript's
|
|
13
|
+
* attributes on <html>, so the first frames already show the choice.
|
|
14
|
+
*/
|
|
15
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
16
|
+
import AnimatedIcons from './AnimatedIcons.astro';
|
|
17
|
+
|
|
18
|
+
type Choice = 'system' | 'light' | 'dark';
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'button'> {
|
|
21
|
+
/** Where the circle grows from. */
|
|
22
|
+
origin?: 'click' | 'center';
|
|
23
|
+
/** The cycle. */
|
|
24
|
+
choices?: Choice[];
|
|
25
|
+
/** Print the current choice next to the icon. */
|
|
26
|
+
label?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { origin = 'click', choices = ['system', 'light', 'dark'], label = false, class: className, ...rest } = Astro.props;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<button
|
|
33
|
+
class:list={['ma-theme', className]}
|
|
34
|
+
type="button"
|
|
35
|
+
data-origin={origin}
|
|
36
|
+
data-choices={choices.join(',')}
|
|
37
|
+
aria-label="Theme: system"
|
|
38
|
+
{...rest}
|
|
39
|
+
>
|
|
40
|
+
<AnimatedIcons icon="sun" />
|
|
41
|
+
{label && <span class="ma-theme__label"></span>}
|
|
42
|
+
</button>
|
|
43
|
+
|
|
44
|
+
<style is:global>
|
|
45
|
+
@layer components {
|
|
46
|
+
:where(.ma-theme) {
|
|
47
|
+
display: inline-flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 0.5em;
|
|
50
|
+
padding: 0.45em 0.7em;
|
|
51
|
+
border: 1px solid var(--ma-edge);
|
|
52
|
+
border-radius: 0.5em;
|
|
53
|
+
background: var(--ma-panel);
|
|
54
|
+
color: var(--ma-ink);
|
|
55
|
+
font: inherit;
|
|
56
|
+
font-size: 0.875em;
|
|
57
|
+
line-height: 1.2;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
transition: border-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
60
|
+
}
|
|
61
|
+
/* no ThemeScript (no data-theme-choice), which includes no JavaScript at all: a toggle that cannot toggle is not shown */
|
|
62
|
+
:where(:root:not([data-theme-choice]) .ma-theme) {
|
|
63
|
+
display: none;
|
|
64
|
+
}
|
|
65
|
+
:where(.ma-theme:hover) {
|
|
66
|
+
border-color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
|
|
67
|
+
}
|
|
68
|
+
:where(.ma-theme:focus-visible) {
|
|
69
|
+
outline: 2px solid currentColor;
|
|
70
|
+
outline-offset: 2px;
|
|
71
|
+
}
|
|
72
|
+
:where(.ma-theme__label) {
|
|
73
|
+
font-family: ui-monospace, monospace;
|
|
74
|
+
font-size: 0.85em;
|
|
75
|
+
min-width: 3.6em;
|
|
76
|
+
text-align: left;
|
|
77
|
+
}
|
|
78
|
+
/* the first frames, before the script has synced: ThemeScript has already stamped the choice and the resolved theme
|
|
79
|
+
on <html>, so the label reads the choice and the icon is the moon in dark. The script then writes the label's
|
|
80
|
+
text (:empty lets go) and data-on. The icon rules outrank AnimatedIcons' own zero-specificity ones, so the order
|
|
81
|
+
the two stylesheets arrive in does not matter */
|
|
82
|
+
:where(.ma-theme__label:empty)::before {
|
|
83
|
+
content: 'system';
|
|
84
|
+
}
|
|
85
|
+
:where(:root[data-theme-choice='light'] .ma-theme__label:empty)::before {
|
|
86
|
+
content: 'light';
|
|
87
|
+
}
|
|
88
|
+
:where(:root[data-theme-choice='dark'] .ma-theme__label:empty)::before {
|
|
89
|
+
content: 'dark';
|
|
90
|
+
}
|
|
91
|
+
:where(:root[data-theme='dark'] .ma-theme .ma-icon[data-icon='sun']) .ma-icon__a,
|
|
92
|
+
:where(:root[data-theme='dark'] .ma-theme .ma-icon[data-icon='sun']) .ma-icon__b {
|
|
93
|
+
opacity: 0;
|
|
94
|
+
scale: 0.5;
|
|
95
|
+
rotate: 90deg;
|
|
96
|
+
}
|
|
97
|
+
:where(:root[data-theme='dark'] .ma-theme .ma-icon[data-icon='sun']) .ma-icon__c {
|
|
98
|
+
opacity: 1;
|
|
99
|
+
scale: 1;
|
|
100
|
+
rotate: 0deg;
|
|
101
|
+
}
|
|
102
|
+
/* the swap: the old theme holds, the new one opens as a circle from the origin. The attribute lives on <html>
|
|
103
|
+
only for the duration, and its specificity beats Curtain's :where() root rules so both can share a page. */
|
|
104
|
+
:where(:root)[data-ma-theme-vt]::view-transition-old(root) {
|
|
105
|
+
animation: none;
|
|
106
|
+
mix-blend-mode: normal;
|
|
107
|
+
}
|
|
108
|
+
:where(:root)[data-ma-theme-vt]::view-transition-new(root) {
|
|
109
|
+
animation: ma-theme-iris var(--ma-duration) var(--ma-ease-out) both;
|
|
110
|
+
mix-blend-mode: normal;
|
|
111
|
+
}
|
|
112
|
+
@media (prefers-reduced-motion: reduce) {
|
|
113
|
+
:where(:root)[data-ma-theme-vt]::view-transition-old(root),
|
|
114
|
+
:where(:root)[data-ma-theme-vt]::view-transition-new(root) {
|
|
115
|
+
animation: none;
|
|
116
|
+
clip-path: none;
|
|
117
|
+
}
|
|
118
|
+
:where(:root)[data-ma-theme-vt]::view-transition-old(root) {
|
|
119
|
+
display: none;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@keyframes ma-theme-iris {
|
|
125
|
+
from {
|
|
126
|
+
clip-path: circle(0 at var(--ma-theme-x, 50%) var(--ma-theme-y, 50%));
|
|
127
|
+
}
|
|
128
|
+
to {
|
|
129
|
+
clip-path: circle(var(--ma-theme-r, 150%) at var(--ma-theme-x, 50%) var(--ma-theme-y, 50%));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
|
|
134
|
+
<script>
|
|
135
|
+
import { onMount, prefersReducedMotion } from '../lib/runtime';
|
|
136
|
+
|
|
137
|
+
type Api = { choice(): string; set(c: string): void };
|
|
138
|
+
const html = document.documentElement;
|
|
139
|
+
// every toggle on the page follows a change made on any of them
|
|
140
|
+
const syncs = new Set<() => void>();
|
|
141
|
+
|
|
142
|
+
// bound by tag and class, not by an attribute: data-ma-theme is theme.css's scope (7F) — a toggle bound to it turned every
|
|
143
|
+
// themed subtree (a section demo, the /theme preview) into a second toggle that cycled the theme on any click inside it
|
|
144
|
+
onMount<HTMLButtonElement>('button.ma-theme', (btn, { signal }) => {
|
|
145
|
+
// ThemeScript owns the attributes and exposes the API; without it the toggle stays inert rather than half-working
|
|
146
|
+
const theme = (window as unknown as { __maTheme?: Api }).__maTheme;
|
|
147
|
+
if (!theme) return;
|
|
148
|
+
const choices = (btn.dataset.choices ?? 'system,light,dark').split(',');
|
|
149
|
+
const sync = () => {
|
|
150
|
+
const c = theme.choice();
|
|
151
|
+
btn.setAttribute('aria-label', `Theme: ${c}`);
|
|
152
|
+
const l = btn.querySelector('.ma-theme__label');
|
|
153
|
+
if (l) l.textContent = c;
|
|
154
|
+
btn.toggleAttribute('data-on', html.dataset.theme === 'dark');
|
|
155
|
+
};
|
|
156
|
+
syncs.add(sync);
|
|
157
|
+
signal.addEventListener('abort', () => syncs.delete(sync));
|
|
158
|
+
btn.addEventListener(
|
|
159
|
+
'click',
|
|
160
|
+
() => {
|
|
161
|
+
// a choice outside the cycle (system, with choices light and dark) steps on from the theme it resolved to, so the
|
|
162
|
+
// first click changes the page instead of setting the theme it already shows
|
|
163
|
+
const next = choices[(choices.indexOf(theme.choice()) + 1 || choices.indexOf(html.dataset.theme!) + 1) % choices.length]!;
|
|
164
|
+
const apply = () => {
|
|
165
|
+
theme.set(next);
|
|
166
|
+
syncs.forEach((s) => s());
|
|
167
|
+
};
|
|
168
|
+
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
|
|
169
|
+
if (!doc.startViewTransition || prefersReducedMotion()) return apply();
|
|
170
|
+
const r = btn.getBoundingClientRect();
|
|
171
|
+
const centre = btn.dataset.origin === 'center';
|
|
172
|
+
const x = centre ? innerWidth / 2 : r.left + r.width / 2;
|
|
173
|
+
const y = centre ? innerHeight / 2 : r.top + r.height / 2;
|
|
174
|
+
html.style.cssText += `;--ma-theme-x:${x}px;--ma-theme-y:${y}px;--ma-theme-r:${Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))}px`;
|
|
175
|
+
html.setAttribute('data-ma-theme-vt', '');
|
|
176
|
+
doc.startViewTransition(apply).finished.finally(() => html.removeAttribute('data-ma-theme-vt'));
|
|
177
|
+
},
|
|
178
|
+
{ signal },
|
|
179
|
+
);
|
|
180
|
+
sync();
|
|
181
|
+
});
|
|
182
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'ThemeToggle',
|
|
5
|
+
slug: 'theme-toggle',
|
|
6
|
+
title: 'Theme Toggle',
|
|
7
|
+
description: 'Cycles system → light → dark, and the page changes theme through a circle that grows from the click, on a same-document view transition. ThemeScript in <head> stores the choice, applies it before paint and after every swap and exposes the API; ThemeToggle\'s own script only advances the choice and wraps the change (its storageKey prop is ThemeScript\'s). Without view transitions, or under reduced motion, the theme switches at once. Without ThemeScript, which includes a page without JavaScript, the toggle is hidden. The icon is AnimatedIcons (sun to moon).',
|
|
8
|
+
summary: 'A dark mode toggle that cycles system, light and dark, showing the new theme in a circle that grows from the click.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'C',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['view-transitions', 'clip-path'], fallback: 'an instant switch' },
|
|
13
|
+
readout: '<ThemeToggle origin="click">',
|
|
14
|
+
usage: `---\n// in the layout\nimport ThemeScript from '@moonarc/core/ThemeScript';\nimport ThemeToggle from '@moonarc/core/ThemeToggle';\n---\n<head>\n <ThemeScript />\n</head>\n<header>\n <ThemeToggle label />\n</header>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'origin', type: "'click' | 'center'", default: "'click'", description: 'Where the circle grows from.' },
|
|
17
|
+
{ name: 'choices', type: "('system' | 'light' | 'dark')[]", default: "['system', 'light', 'dark']", description: 'The cycle; ["light", "dark"] for a two-state switch. A stored choice outside the cycle (system, for that switch) steps on from the theme it resolves to, so the first click always changes the page.' },
|
|
18
|
+
{ name: 'label', type: 'boolean', default: 'false', description: 'Print the current choice next to the icon.' },
|
|
19
|
+
],
|
|
20
|
+
reducedMotion: 'No circle: the theme switches at once. The icon swap loses its rotation.',
|
|
21
|
+
clientRouter: 'ThemeScript re-applies the attributes after every swap (the router replaces <html>\'s); the toggle rebinds through the shared runtime and re-syncs its label and icon.',
|
|
22
|
+
craft: [
|
|
23
|
+
'One contract, two halves: ThemeScript (inline, in <head>, before paint) owns the attributes and the storage key; the toggle only calls set(). No flash on load, none on navigation. Without ThemeScript there is no data-theme-choice on <html>, and the toggle is display: none rather than a dead button or a second, half-right owner. The same rule hides it when JavaScript is off.',
|
|
24
|
+
'The first frames are CSS: before the toggle\'s module has run, the label reads the choice from html[data-theme-choice] and the icon turns to the moon under html[data-theme="dark"], so a reader who chose dark never sees a sun and the word system flash first.',
|
|
25
|
+
'The circle is clip-path on ::view-transition-new(root) with the old snapshot held still. The new theme is revealed, not faded, so mid-transition both themes are crisp.',
|
|
26
|
+
'The radius is computed to the farthest corner, so the circle covers the viewport exactly when the transition ends, from any click point.',
|
|
27
|
+
'The attribute that selects the transition rules lives on <html> only for the duration and outranks Curtain\'s :where() root rules, so a page can carry both.',
|
|
28
|
+
'Three choices: system is a choice, and a visitor who chose it should not be forced into a fixed theme by a click.',
|
|
29
|
+
],
|
|
30
|
+
related: ['animated-icons', 'curtain', 'page-transition'],
|
|
31
|
+
intents: ['dark mode toggle', 'theme toggle with animation', 'theme switch circle transition', 'view transition dark mode', 'light dark system toggle astro'],
|
|
32
|
+
replaces: ['ThemeToggle (Skiper UI)', 'AnimatedThemeToggler (Magic UI)', 'theme toggle (shadcn)'],
|
|
33
|
+
fixture: { props: { label: true }, expect: 'aria-label="Theme: system"' },
|
|
34
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Tilt — a card leans toward the cursor in 3D and settles back when it leaves.
|
|
4
|
+
*
|
|
5
|
+
* Pointer position becomes two custom properties; the rotation, lift and
|
|
6
|
+
* glare are CSS reading them. Moving is immediate; leaving transitions on the
|
|
7
|
+
* active preset's curve so the settle has the same character as everything
|
|
8
|
+
* else on the page. Under reduced motion the script never binds.
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
11
|
+
|
|
12
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
13
|
+
/** Element to render. */
|
|
14
|
+
as?: HTMLTag;
|
|
15
|
+
/** Maximum tilt in degrees. */
|
|
16
|
+
max?: number;
|
|
17
|
+
/** Scale while hovered. */
|
|
18
|
+
scale?: number;
|
|
19
|
+
/** Perspective in px; smaller is more dramatic. */
|
|
20
|
+
perspective?: number;
|
|
21
|
+
/** A moving highlight on the surface. */
|
|
22
|
+
glare?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { as: Tag = 'div', max = 12, scale = 1.02, perspective = 800, glare = true, class: className, style, ...rest } = Astro.props;
|
|
26
|
+
const inline = [`--ma-tilt-max:${max}deg`, `--ma-tilt-scale:${scale}`, `--ma-tilt-p:${perspective}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<Tag class:list={['ma-tilt', className]} data-ma-tilt data-glare={glare ? '' : undefined} style={inline} {...rest}>
|
|
30
|
+
<slot />
|
|
31
|
+
</Tag>
|
|
32
|
+
|
|
33
|
+
<style is:global>
|
|
34
|
+
@layer components {
|
|
35
|
+
:where(.ma-tilt) {
|
|
36
|
+
position: relative;
|
|
37
|
+
/* --ma-px / --ma-py: pointer position as -1…1 from the centre, written by the script */
|
|
38
|
+
transform: perspective(var(--ma-tilt-p, 800px)) rotateX(calc(var(--ma-py, 0) * var(--ma-tilt-max, 12deg) * -1)) rotateY(calc(var(--ma-px, 0) * var(--ma-tilt-max, 12deg))) scale(var(--ma-s, 1));
|
|
39
|
+
transform-style: preserve-3d;
|
|
40
|
+
transition: transform var(--ma-duration) var(--ma-ease);
|
|
41
|
+
}
|
|
42
|
+
:where(.ma-tilt[data-moving]) {
|
|
43
|
+
transition-duration: 80ms;
|
|
44
|
+
transition-timing-function: linear;
|
|
45
|
+
}
|
|
46
|
+
:where(.ma-tilt[data-glare])::after {
|
|
47
|
+
content: '';
|
|
48
|
+
position: absolute;
|
|
49
|
+
inset: 0;
|
|
50
|
+
border-radius: inherit;
|
|
51
|
+
pointer-events: none;
|
|
52
|
+
background: radial-gradient(farthest-corner circle at calc(50% + var(--ma-px, 0) * 50%) calc(50% + var(--ma-py, 0) * 50%), var(--ma-shine), transparent 60%);
|
|
53
|
+
opacity: 0;
|
|
54
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
55
|
+
}
|
|
56
|
+
:where(.ma-tilt[data-moving][data-glare])::after {
|
|
57
|
+
opacity: 0.35;
|
|
58
|
+
}
|
|
59
|
+
@media (prefers-reduced-motion: reduce) {
|
|
60
|
+
:where(.ma-tilt) {
|
|
61
|
+
transform: none;
|
|
62
|
+
transition: none;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
</style>
|
|
67
|
+
|
|
68
|
+
<script>
|
|
69
|
+
import { onMount } from '../lib/runtime';
|
|
70
|
+
|
|
71
|
+
onMount<HTMLElement>('[data-ma-tilt]', (el, { signal, reducedMotion }) => {
|
|
72
|
+
if (reducedMotion) return;
|
|
73
|
+
const set = (x: number, y: number) => {
|
|
74
|
+
el.style.setProperty('--ma-px', x.toFixed(3));
|
|
75
|
+
el.style.setProperty('--ma-py', y.toFixed(3));
|
|
76
|
+
};
|
|
77
|
+
el.addEventListener(
|
|
78
|
+
'pointermove',
|
|
79
|
+
(e) => {
|
|
80
|
+
const r = el.getBoundingClientRect();
|
|
81
|
+
set(((e.clientX - r.left) / r.width) * 2 - 1, ((e.clientY - r.top) / r.height) * 2 - 1);
|
|
82
|
+
el.style.setProperty('--ma-s', el.style.getPropertyValue('--ma-tilt-scale') || '1.02');
|
|
83
|
+
el.dataset.moving = '';
|
|
84
|
+
},
|
|
85
|
+
{ signal, passive: true },
|
|
86
|
+
);
|
|
87
|
+
el.addEventListener(
|
|
88
|
+
'pointerleave',
|
|
89
|
+
() => {
|
|
90
|
+
delete el.dataset.moving;
|
|
91
|
+
set(0, 0);
|
|
92
|
+
el.style.setProperty('--ma-s', '1');
|
|
93
|
+
},
|
|
94
|
+
{ signal },
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Tilt',
|
|
5
|
+
slug: 'tilt',
|
|
6
|
+
title: 'Tilt',
|
|
7
|
+
description: 'A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset\'s spring when the cursor leaves. The pointer writes two custom properties; everything visible is CSS. Never binds under reduced motion.',
|
|
8
|
+
summary: 'A 3D tilt card effect: the card leans toward the cursor, lifts, catches a glare and springs back.',
|
|
9
|
+
category: 'pointer',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['pointer', 'hover'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<Tilt max={12}>',
|
|
14
|
+
usage: `<Tilt class="rounded-2xl border p-8">\n <img src="/card.png" alt="" />\n</Tilt>\n<Tilt max={6} glare={false} perspective={1200}>subtle</Tilt>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'as', type: 'HTMLTag', default: "'div'", description: 'Element to render.' },
|
|
17
|
+
{ name: 'max', type: 'number', default: '12', description: 'Maximum tilt in degrees. Over 15 shows the card\'s edge.' },
|
|
18
|
+
{ name: 'scale', type: 'number', default: '1.02', description: 'Scale while hovered.' },
|
|
19
|
+
{ name: 'perspective', type: 'number', default: '800', description: 'Perspective in px; smaller is more dramatic.' },
|
|
20
|
+
{ name: 'glare', type: 'boolean', default: 'true', description: 'A highlight that moves opposite the tilt.' },
|
|
21
|
+
],
|
|
22
|
+
reducedMotion: 'No tilt, no glare, no script: the card is static.',
|
|
23
|
+
clientRouter: 'Bound through the shared runtime: every instance after each navigation, aborted before the swap.',
|
|
24
|
+
craft: [
|
|
25
|
+
'Following the cursor is near-instant (80 ms linear); returning uses the preset spring. Direct manipulation must not lag; release may have character.',
|
|
26
|
+
'The settle curve is the page\'s active preset, so a "lively" page has cards that bounce back and a "snap" page has cards that click back.',
|
|
27
|
+
'Rotation and glare both read the same two custom properties, so they can never disagree.',
|
|
28
|
+
'transform-style: preserve-3d lets children with translateZ float above the surface.',
|
|
29
|
+
],
|
|
30
|
+
related: ['spotlight', 'border-beam', 'magnetic'],
|
|
31
|
+
intents: ['tilt card', '3d hover card', 'card tilts on mouse move', 'parallax tilt effect', 'vanilla-tilt astro'],
|
|
32
|
+
replaces: ['TiltedCard (React Bits)', 'CardContainer / 3D card (Aceternity)', 'vanilla-tilt', 'react-parallax-tilt'],
|
|
33
|
+
fixture: { expect: 'class="ma-tilt"' },
|
|
34
|
+
} satisfies ComponentMeta;
|