@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,32 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Parallax',
|
|
5
|
+
slug: 'parallax',
|
|
6
|
+
title: 'Parallax',
|
|
7
|
+
description: 'An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll. Zero JavaScript: a translate bound to the element\'s own view timeline. Stack a few with different speeds for depth.',
|
|
8
|
+
summary: 'A parallax effect: an element drifts at its own speed as it crosses the viewport, in step with the scroll.',
|
|
9
|
+
category: 'scroll',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['scroll'],
|
|
12
|
+
support: { baseline: 'limited', needs: ['scroll-timeline'], fallback: 'the element stays in place (Firefox)' },
|
|
13
|
+
readout: '<Parallax speed={0.3}>',
|
|
14
|
+
usage: `<div class="relative">\n <Parallax speed={-0.2} class="absolute inset-0"><img src="/bg.jpg" alt="" /></Parallax>\n <Parallax speed={0.4}><h2 class="relative">Foreground moves faster</h2></Parallax>\n</div>\n\n<!-- sideways: clip the row, or the drift widens the page and it scrolls horizontally -->\n<div class="overflow-x-clip">\n <Parallax axis="x" speed={0.2}><p class="text-6xl">Moving type</p></Parallax>\n</div>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'as', type: 'HTMLTag', default: "'div'", description: 'Element to render.' },
|
|
17
|
+
{ name: 'speed', type: 'number', default: '0.3', description: 'Drift over the element\'s full passage, as a fraction of the viewport: 0.3 travels 30 vh. Negative moves against the scroll. Keep |speed| under 0.5 or the element visits places it should not.' },
|
|
18
|
+
{ name: 'axis', type: "'y' | 'x'", default: "'y'", description: 'Axis of travel. For x, give the parent overflow-x: clip: a translated element still counts toward the page\'s scrollable width, so the drift would add a horizontal scrollbar.' },
|
|
19
|
+
],
|
|
20
|
+
reducedMotion: 'No drift; the element scrolls with the page.',
|
|
21
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
22
|
+
craft: [
|
|
23
|
+
'view() with range cover 0% → 100%: the drift starts the moment the element enters and ends the moment it leaves, so it is never mid-motion while static.',
|
|
24
|
+
'Linear timing: scroll-linked motion must map 1:1 to the scroll or it feels detached.',
|
|
25
|
+
'translate, not top: the compositor moves it; layout is untouched.',
|
|
26
|
+
'The parent must reserve the space the element drifts through, or it will overlap neighbours. Sideways, it must also clip (overflow-x: clip, which unlike hidden makes no scroll container and so keeps the view timeline on the page), or the drift widens the page.',
|
|
27
|
+
],
|
|
28
|
+
related: ['reveal', 'scroll-scrub', 'card-stack'],
|
|
29
|
+
intents: ['parallax', 'parallax scroll effect', 'background moves slower than foreground', 'scroll-driven parallax astro', 'parallax without javascript'],
|
|
30
|
+
replaces: ['react-scroll-parallax', 'Rellax', 'GSAP ScrollTrigger for simple parallax'],
|
|
31
|
+
fixture: { expect: 'class="ma-parallax"' },
|
|
32
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Particles — a field of drifting dots that part around the cursor, on a
|
|
4
|
+
* canvas that only draws while it is on screen and the tab is visible. The
|
|
5
|
+
* count follows the box's area (capped, so a phone never lifts two hundred
|
|
6
|
+
* points), every constant — speed, reach, push, friction, link distance —
|
|
7
|
+
* is a custom property this stylesheet declares, and the colours are the
|
|
8
|
+
* theme's, re-read when it changes. Without JavaScript the host shows a
|
|
9
|
+
* still dot pattern in CSS; under reduced motion the canvas draws one
|
|
10
|
+
* frame and stops. The lifecycle (DPR cap, ResizeObserver, visibility
|
|
11
|
+
* gate, theme observer) is the shared canvas helper. Fills its positioned
|
|
12
|
+
* parent.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
15
|
+
|
|
16
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
17
|
+
/** Dots; default from the area, at most 160. */
|
|
18
|
+
count?: number;
|
|
19
|
+
/** Dot colour. */
|
|
20
|
+
color?: string;
|
|
21
|
+
/** Drift in px per second. */
|
|
22
|
+
speed?: number;
|
|
23
|
+
/** Radius around the cursor that pushes dots away, px; 0 turns it off. */
|
|
24
|
+
reach?: number;
|
|
25
|
+
/** Largest dot radius in px. */
|
|
26
|
+
size?: number;
|
|
27
|
+
/** Draw lines between dots closer than this, px; 0 for none. */
|
|
28
|
+
links?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { count, color = 'color-mix(in srgb, var(--ma-ink) 55%, transparent)', speed = 18, reach = 120, size = 1.8, links = 0, class: className, style, ...rest } = Astro.props;
|
|
32
|
+
const inline = [count ? `--ma-particles-count:${count}` : '', `--ma-particles-color:${color}`, `--ma-particles-speed:${speed}`, `--ma-particles-reach:${reach}`, `--ma-particles-size:${size}`, `--ma-particles-links:${links}`, typeof style === 'string' ? style : '']
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join(';');
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<div class:list={['ma-particles', className]} data-ma-particles aria-hidden="true" style={inline} {...rest}>
|
|
38
|
+
<canvas></canvas>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<style is:global>
|
|
42
|
+
@layer components {
|
|
43
|
+
:where(.ma-particles) {
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset: 0;
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
pointer-events: none;
|
|
48
|
+
contain: strict;
|
|
49
|
+
/* physics: --ma-particles-speed px/s · reach px · push (impulse per px inside the reach) · friction (impulse kept per frame) */
|
|
50
|
+
--ma-particles-push: 0.12;
|
|
51
|
+
--ma-particles-friction: 0.9;
|
|
52
|
+
--ma-particles-density: 5000;
|
|
53
|
+
}
|
|
54
|
+
:where([data-ma-preset='snap'] .ma-particles) {
|
|
55
|
+
--ma-particles-push: 0.2;
|
|
56
|
+
--ma-particles-friction: 0.85;
|
|
57
|
+
}
|
|
58
|
+
:where([data-ma-preset='ambient'] .ma-particles),
|
|
59
|
+
:where([data-ma-preset='gentle'] .ma-particles) {
|
|
60
|
+
--ma-particles-push: 0.08;
|
|
61
|
+
--ma-particles-friction: 0.94;
|
|
62
|
+
}
|
|
63
|
+
/* without a script the same dots, still, in CSS; the canvas replaces them once it has drawn */
|
|
64
|
+
:where(.ma-particles)::before {
|
|
65
|
+
content: '';
|
|
66
|
+
position: absolute;
|
|
67
|
+
inset: 0;
|
|
68
|
+
/* --ma-particles-size is a number (the script reads it); the gradient wants a length */
|
|
69
|
+
background-image: radial-gradient(circle, var(--ma-particles-color) calc(var(--ma-particles-size, 1.8) * 1px), transparent calc(var(--ma-particles-size, 1.8) * 1px + 0.6px));
|
|
70
|
+
background-size: 56px 56px;
|
|
71
|
+
background-position: 11px 19px;
|
|
72
|
+
mask-image: radial-gradient(ellipse at center, #000 40%, transparent 80%);
|
|
73
|
+
transition: opacity var(--ma-duration) var(--ma-ease);
|
|
74
|
+
}
|
|
75
|
+
:where(.ma-particles[data-live])::before {
|
|
76
|
+
opacity: 0;
|
|
77
|
+
}
|
|
78
|
+
:where(.ma-particles canvas) {
|
|
79
|
+
position: absolute;
|
|
80
|
+
inset: 0;
|
|
81
|
+
width: 100%;
|
|
82
|
+
height: 100%;
|
|
83
|
+
pointer-events: auto;
|
|
84
|
+
/* the theme-resolved colours the script reads: dots, links */
|
|
85
|
+
color: var(--ma-particles-color);
|
|
86
|
+
border: 0 solid;
|
|
87
|
+
border-top-color: var(--ma-particles-color);
|
|
88
|
+
}
|
|
89
|
+
/* the pointer is read on the host; the canvas takes events so the field answers the cursor while nothing above it is covered */
|
|
90
|
+
@media (pointer: coarse) {
|
|
91
|
+
:where(.ma-particles canvas) {
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
@media (prefers-reduced-motion: reduce) {
|
|
96
|
+
:where(.ma-particles)::before {
|
|
97
|
+
transition: none;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
102
|
+
|
|
103
|
+
<script>
|
|
104
|
+
import { onMount, prefersReducedMotion } from '../lib/runtime';
|
|
105
|
+
import { colors, fit, loop, num, onTheme, visible } from '../lib/canvas';
|
|
106
|
+
|
|
107
|
+
onMount<HTMLElement>('[data-ma-particles]', (el, { signal, reducedMotion }) => {
|
|
108
|
+
const canvas = el.querySelector('canvas')!;
|
|
109
|
+
let ctx: CanvasRenderingContext2D;
|
|
110
|
+
let w = 0;
|
|
111
|
+
let h = 0;
|
|
112
|
+
// x, y, drift x, drift y, impulse x, impulse y, radius
|
|
113
|
+
let ps: number[][] = [];
|
|
114
|
+
let dot = '';
|
|
115
|
+
let line = '';
|
|
116
|
+
let px = -1e4;
|
|
117
|
+
let py = -1e4;
|
|
118
|
+
const v = (n: string, f: number) => num(el, '--ma-particles-' + n, f);
|
|
119
|
+
const seed = () => {
|
|
120
|
+
const n = Math.min(160, Math.round(v('count', (w * h) / v('density', 5000))));
|
|
121
|
+
const s = v('speed', 18) / 1000;
|
|
122
|
+
const r = v('size', 1.8);
|
|
123
|
+
ps = Array.from({ length: n }, () => {
|
|
124
|
+
const a = Math.random() * Math.PI * 2;
|
|
125
|
+
const k = s * (0.4 + Math.random() * 0.8);
|
|
126
|
+
return [Math.random() * w, Math.random() * h, Math.cos(a) * k, Math.sin(a) * k, 0, 0, r * (0.4 + Math.random() * 0.6)];
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
const theme = () => ([dot, line] = colors(canvas));
|
|
130
|
+
const frame = (_t: number, dt: number) => {
|
|
131
|
+
const reach = v('reach', 120);
|
|
132
|
+
const push = v('push', 0.12);
|
|
133
|
+
const fr = v('friction', 0.9);
|
|
134
|
+
const link = v('links', 0);
|
|
135
|
+
ctx.clearRect(0, 0, w, h);
|
|
136
|
+
ctx.fillStyle = dot;
|
|
137
|
+
ctx.strokeStyle = line;
|
|
138
|
+
ctx.lineWidth = 0.5;
|
|
139
|
+
for (const p of ps) {
|
|
140
|
+
if (reach > 0) {
|
|
141
|
+
const dx = p[0]! - px;
|
|
142
|
+
const dy = p[1]! - py;
|
|
143
|
+
const d = Math.hypot(dx, dy);
|
|
144
|
+
if (d < reach && d > 0) {
|
|
145
|
+
const f = ((reach - d) / reach) * push;
|
|
146
|
+
p[4]! += (dx / d) * f;
|
|
147
|
+
p[5]! += (dy / d) * f;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
p[4]! *= fr;
|
|
151
|
+
p[5]! *= fr;
|
|
152
|
+
p[0]! += p[2]! * dt + p[4]!;
|
|
153
|
+
p[1]! += p[3]! * dt + p[5]!;
|
|
154
|
+
// wrap around the edges
|
|
155
|
+
if (p[0]! < -4) p[0] = w + 4;
|
|
156
|
+
else if (p[0]! > w + 4) p[0] = -4;
|
|
157
|
+
if (p[1]! < -4) p[1] = h + 4;
|
|
158
|
+
else if (p[1]! > h + 4) p[1] = -4;
|
|
159
|
+
ctx.beginPath();
|
|
160
|
+
ctx.arc(p[0]!, p[1]!, p[6]!, 0, 7);
|
|
161
|
+
ctx.fill();
|
|
162
|
+
}
|
|
163
|
+
if (link > 0) {
|
|
164
|
+
ctx.beginPath();
|
|
165
|
+
for (let i = 0; i < ps.length; i++)
|
|
166
|
+
for (let j = i + 1; j < ps.length; j++) {
|
|
167
|
+
const a = ps[i]!;
|
|
168
|
+
const b = ps[j]!;
|
|
169
|
+
const dx = a[0]! - b[0]!;
|
|
170
|
+
const dy = a[1]! - b[1]!;
|
|
171
|
+
if (dx * dx + dy * dy < link * link) {
|
|
172
|
+
ctx.moveTo(a[0]!, a[1]!);
|
|
173
|
+
ctx.lineTo(b[0]!, b[1]!);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
ctx.stroke();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const run = loop(frame, signal);
|
|
180
|
+
let on = false;
|
|
181
|
+
// on screen and motion allowed → run; otherwise one still frame (reduced motion is read live: the page's switch stops a running field)
|
|
182
|
+
const sync = () => (on && !prefersReducedMotion() ? run.wake() : (run.sleep(), run.once()));
|
|
183
|
+
fit(canvas, el, signal, (W, H, c) => {
|
|
184
|
+
w = W;
|
|
185
|
+
h = H;
|
|
186
|
+
ctx = c;
|
|
187
|
+
seed();
|
|
188
|
+
run.once();
|
|
189
|
+
});
|
|
190
|
+
theme();
|
|
191
|
+
onTheme(signal, () => {
|
|
192
|
+
theme();
|
|
193
|
+
sync();
|
|
194
|
+
});
|
|
195
|
+
el.setAttribute('data-live', '');
|
|
196
|
+
visible(el, signal, (o) => ((on = o), sync()));
|
|
197
|
+
if (!reducedMotion && matchMedia('(pointer: fine)').matches) {
|
|
198
|
+
canvas.addEventListener(
|
|
199
|
+
'pointermove',
|
|
200
|
+
(e) => {
|
|
201
|
+
const r = canvas.getBoundingClientRect();
|
|
202
|
+
px = e.clientX - r.left;
|
|
203
|
+
py = e.clientY - r.top;
|
|
204
|
+
},
|
|
205
|
+
{ signal, passive: true },
|
|
206
|
+
);
|
|
207
|
+
canvas.addEventListener('pointerleave', () => (px = py = -1e4), { signal });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Particles',
|
|
5
|
+
slug: 'particles',
|
|
6
|
+
title: 'Particles',
|
|
7
|
+
description: 'A field of drifting dots that part around the cursor, on a canvas that draws only while on screen in a visible tab. The count follows the area (capped), every constant is a custom property declared per preset, the colours are the theme\'s and are re-read when it changes. Without JavaScript a still dot pattern in CSS stands in; under reduced motion one frame is drawn.',
|
|
8
|
+
summary: 'A particle background of drifting dots that part around the cursor, drawn on a canvas only while it is on screen.',
|
|
9
|
+
category: 'background',
|
|
10
|
+
tier: 'C',
|
|
11
|
+
trigger: ['always', 'pointer'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<Particles reach={120} links={0}>',
|
|
14
|
+
usage: `<section class="relative min-h-[28rem]">\n <Particles reach={140} links={90} color="var(--action)" />\n <h1 class="relative">Content above the field</h1>\n</section>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'count', type: 'number', default: 'from the area', description: 'Dots; the default is one per 5 000 px², at most 160.' },
|
|
17
|
+
{ name: 'color', type: 'string', default: 'ink at 55 %', description: 'Dot (and link) colour.' },
|
|
18
|
+
{ name: 'speed', type: 'number', default: '18', description: 'Drift in px per second.' },
|
|
19
|
+
{ name: 'reach', type: 'number', default: '120', description: 'Radius around the cursor that pushes dots away, in px; 0 turns it off. Never on coarse pointers.' },
|
|
20
|
+
{ name: 'size', type: 'number', default: '1.8', description: 'Largest dot radius in px.' },
|
|
21
|
+
{ name: 'links', type: 'number', default: '0', description: 'Draw lines between dots closer than this many px; 0 for none.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'One frame is drawn and the loop never starts; the cursor does not push.',
|
|
24
|
+
clientRouter: 'Bound through the shared runtime: the frame loop, both observers and the listeners are released before the swap; five round trips leave one loop per instance.',
|
|
25
|
+
craft: [
|
|
26
|
+
'The loop runs only while the host intersects the viewport and the tab is visible: content-visibility on a card does not stop a requestAnimationFrame, so the component stops it.',
|
|
27
|
+
'Device pixel ratio is capped at 2 and the size is read once from a ResizeObserver; nothing listens to the resize event.',
|
|
28
|
+
'Push, friction, density and speed are custom properties this stylesheet declares (snap pushes harder and settles faster, ambient drifts), so the field has the page\'s character and the script carries no constant.',
|
|
29
|
+
'The cursor writes an impulse, not a position: dots are pushed and glide back on friction, which reads as water rather than a hole following the mouse.',
|
|
30
|
+
'Colours are read from the canvas element\'s computed style (its color and a border colour the stylesheet maps to the tokens), so oklch, color-mix and the theme all arrive as rgb the context can paint; a MutationObserver on data-theme re-reads them.',
|
|
31
|
+
'Without a script the host paints the same dots as a tiled gradient; the canvas fades it out after its first frame.',
|
|
32
|
+
],
|
|
33
|
+
related: ['dot-pattern', 'meteors', 'globe'],
|
|
34
|
+
intents: ['particles background', 'floating dots interactive', 'particles.js alternative', 'dots avoid cursor', 'constellation background canvas'],
|
|
35
|
+
replaces: ['Particles (Magic UI)', 'Particles (React Bits)', 'SparklesCore (Aceternity)', 'tsparticles'],
|
|
36
|
+
fixture: { props: { count: 20 }, expect: 'class="ma-particles"' },
|
|
37
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PersistPlayer — an audio player that keeps playing across page
|
|
4
|
+
* navigations. The native <audio controls> sits in a wrapper marked
|
|
5
|
+
* transition:persist, so Astro's router moves the very same element into
|
|
6
|
+
* the next page instead of replacing it: playback, position and volume
|
|
7
|
+
* continue. Zero JS of its own; the controls are the browser's. Where
|
|
8
|
+
* :playing exists (Safari 15.4, Firefox 150; not Chrome) the bars move
|
|
9
|
+
* while the track plays.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
12
|
+
|
|
13
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
14
|
+
/** Audio file. */
|
|
15
|
+
src: string;
|
|
16
|
+
/** Track title. */
|
|
17
|
+
title?: string;
|
|
18
|
+
/** Artist or source line. */
|
|
19
|
+
artist?: string;
|
|
20
|
+
/** Loop the track. */
|
|
21
|
+
loop?: boolean;
|
|
22
|
+
/** Persist key; the same key on two pages pairs the players. */
|
|
23
|
+
name?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { src, title, artist, loop = false, name = 'ma-player', class: className, ...rest } = Astro.props;
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<div class:list={['ma-player', className]} transition:persist={name} {...rest}>
|
|
30
|
+
<span class="ma-player__bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
31
|
+
{(title || artist) && (
|
|
32
|
+
<span class="ma-player__meta">
|
|
33
|
+
{title && <b class="ma-player__title">{title}</b>}
|
|
34
|
+
{artist && <span class="ma-player__artist">{artist}</span>}
|
|
35
|
+
</span>
|
|
36
|
+
)}
|
|
37
|
+
<audio class="ma-player__audio" controls preload="metadata" src={src} loop={loop}></audio>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<style is:global>
|
|
41
|
+
@layer components {
|
|
42
|
+
:where(.ma-player) {
|
|
43
|
+
display: grid;
|
|
44
|
+
grid-template-columns: auto 1fr;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 0.6em 0.9em;
|
|
47
|
+
padding: 0.85em 1em;
|
|
48
|
+
border: 1px solid var(--ma-edge);
|
|
49
|
+
border-radius: 0.75em;
|
|
50
|
+
background: var(--ma-panel);
|
|
51
|
+
color: var(--ma-ink);
|
|
52
|
+
}
|
|
53
|
+
:where(.ma-player__bars) {
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: flex-end;
|
|
56
|
+
gap: 2px;
|
|
57
|
+
width: 1.1em;
|
|
58
|
+
height: 1.1em;
|
|
59
|
+
}
|
|
60
|
+
:where(.ma-player__bars > i) {
|
|
61
|
+
flex: 1;
|
|
62
|
+
height: 100%;
|
|
63
|
+
border-radius: 1px;
|
|
64
|
+
background: currentColor;
|
|
65
|
+
transform-origin: 50% 100%;
|
|
66
|
+
scale: 1 0.35;
|
|
67
|
+
/* the bar's index, named for the player and set on every bar (0 here): a page's own --ma-i must not delay the first */
|
|
68
|
+
--ma-player-i: 0;
|
|
69
|
+
animation: ma-player-bar var(--ma-dur-ambient) var(--ma-ease-in-out) calc(var(--ma-player-i) * var(--ma-stagger-relaxed)) infinite alternate;
|
|
70
|
+
animation-play-state: paused;
|
|
71
|
+
}
|
|
72
|
+
:where(.ma-player__bars > i:nth-child(2)) {
|
|
73
|
+
--ma-player-i: 1;
|
|
74
|
+
scale: 1 0.7;
|
|
75
|
+
}
|
|
76
|
+
:where(.ma-player__bars > i:nth-child(3)) {
|
|
77
|
+
--ma-player-i: 2;
|
|
78
|
+
scale: 1 0.5;
|
|
79
|
+
}
|
|
80
|
+
:where(.ma-player__bars > i:nth-child(4)) {
|
|
81
|
+
--ma-player-i: 3;
|
|
82
|
+
scale: 1 0.85;
|
|
83
|
+
}
|
|
84
|
+
/* :playing exists in WebKit and Firefox (150), not in Chrome; :where() forgives the selector there and the bars stay still */
|
|
85
|
+
:where(.ma-player:has(.ma-player__audio:playing) .ma-player__bars > i) {
|
|
86
|
+
animation-play-state: running;
|
|
87
|
+
}
|
|
88
|
+
:where(.ma-player__meta) {
|
|
89
|
+
display: grid;
|
|
90
|
+
min-width: 0;
|
|
91
|
+
line-height: 1.3;
|
|
92
|
+
}
|
|
93
|
+
:where(.ma-player__title) {
|
|
94
|
+
overflow: hidden;
|
|
95
|
+
text-overflow: ellipsis;
|
|
96
|
+
white-space: nowrap;
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-player__artist) {
|
|
99
|
+
font-size: 0.8125em;
|
|
100
|
+
opacity: var(--ma-dim, 0.7);
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
text-overflow: ellipsis;
|
|
103
|
+
white-space: nowrap;
|
|
104
|
+
}
|
|
105
|
+
:where(.ma-player__audio) {
|
|
106
|
+
grid-column: 1 / -1;
|
|
107
|
+
width: 100%;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
height: 2.25em;
|
|
110
|
+
}
|
|
111
|
+
@media (prefers-reduced-motion: reduce) {
|
|
112
|
+
:where(.ma-player__bars > i) {
|
|
113
|
+
animation: none;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@keyframes ma-player-bar {
|
|
119
|
+
from {
|
|
120
|
+
scale: 1 0.25;
|
|
121
|
+
}
|
|
122
|
+
to {
|
|
123
|
+
scale: 1 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'PersistPlayer',
|
|
5
|
+
slug: 'persist-player',
|
|
6
|
+
title: 'Persist Player',
|
|
7
|
+
description:
|
|
8
|
+
'An audio player that keeps playing while you navigate: the same <audio> element is carried into the next page by Astro\'s router, so position, volume and playback never restart. Native controls, zero JavaScript of its own. Put it in the layout; every page gets the one that is already playing.',
|
|
9
|
+
summary: "An audio player that keeps playing across page navigations, carried into the next page by Astro's router.",
|
|
10
|
+
category: 'transition',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['click'],
|
|
13
|
+
support: { baseline: 'widely', needs: [], fallback: 'without the router the player is a normal audio element that restarts with the page' },
|
|
14
|
+
readout: '<PersistPlayer src="/audio/loop.mp3">',
|
|
15
|
+
usage: `---\n// in the layout, so every page has it\n---\n<PersistPlayer src="/audio/loop.mp3" title="Ambient loop" artist="synthesised, CC0" loop />`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'src', type: 'string', description: 'Audio file. MP3 plays everywhere; keep it small (this site\'s 20-second loop is a 98 kB MP3).' },
|
|
18
|
+
{ name: 'title', type: 'string', description: 'Track title.' },
|
|
19
|
+
{ name: 'artist', type: 'string', description: 'Artist or source line.' },
|
|
20
|
+
{ name: 'loop', type: 'boolean', default: 'false', description: 'Loop the track.' },
|
|
21
|
+
{ name: 'name', type: 'string', default: "'ma-player'", description: 'Persist key. The same key on two pages pairs the players; a page without it drops the player and stops the audio.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'The bars beside the title stay still; the player is unaffected.',
|
|
24
|
+
clientRouter: 'PersistPlayer is built on the router: transition:persist moves the existing element into the new document during the swap, so the media element, its buffer and its play state are the same object before and after.',
|
|
25
|
+
craft: [
|
|
26
|
+
'The element persists, not the state: no script serialises the position and none restores it. The browser keeps decoding because nothing told it to stop.',
|
|
27
|
+
'Native controls. Play, seek, volume, the media session and the OS media keys are the browser\'s; a custom UI would need script for every one of them.',
|
|
28
|
+
'The equalizer bars run only where :playing exists (Safari 15.4, Firefox 150; Chrome has none yet) and stay still elsewhere. :where() forgives the selector, so the rule is harmless in engines without it. Nothing is faked.',
|
|
29
|
+
'The persist key is a prop, so two players on one site can coexist and a page that should stop the music simply leaves the player out.',
|
|
30
|
+
],
|
|
31
|
+
related: ['curtain', 'page-transition', 'marquee'],
|
|
32
|
+
intents: ['persistent audio player astro', 'music keeps playing between pages', 'transition:persist audio', 'site-wide player view transitions', 'audio survives navigation'],
|
|
33
|
+
replaces: ['SPA global players (React context + Howler)', 'iframes kept alive for audio'],
|
|
34
|
+
fixture: { props: { src: '/fixture.mp3', title: 'Fixture content for PersistPlayer' }, expect: 'Fixture content for PersistPlayer' },
|
|
35
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PhoneFrame — a generic phone around a screenshot or any content, zero
|
|
4
|
+
* JS. No brand: three cut-outs (`island`, `notch`, `punch`), a home bar,
|
|
5
|
+
* no logo and no buttons. The chrome scales with the frame's width through
|
|
6
|
+
* container query units, like BrowserFrame, and uses the same tokens for
|
|
7
|
+
* the bezel, the screen and the shadow. `scroll` drifts content taller
|
|
8
|
+
* than the screen up and back on a translate keyframe — one ambient
|
|
9
|
+
* duration times six, paused under the pointer and while anything inside
|
|
10
|
+
* has focus. Pass `src` for a screenshot or fill the slot.
|
|
11
|
+
*/
|
|
12
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
13
|
+
import type { ImageMetadata } from 'astro';
|
|
14
|
+
import { Image } from 'astro:assets';
|
|
15
|
+
|
|
16
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
17
|
+
model?: 'island' | 'notch' | 'punch';
|
|
18
|
+
/** A screenshot: an imported image (optimised by Astro) or a plain URL. */
|
|
19
|
+
src?: ImageMetadata | string;
|
|
20
|
+
alt?: string;
|
|
21
|
+
/** Drift content taller than the screen up and back. */
|
|
22
|
+
scroll?: boolean;
|
|
23
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
24
|
+
/** Frame width, any CSS length; the height follows the model's ratio. */
|
|
25
|
+
width?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { model = 'island', src, alt = '', scroll = false, theme = 'auto', width = '18rem', class: className, style, ...rest } = Astro.props;
|
|
29
|
+
const vars = [`--ma-phone-w:${width}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<div class:list={['ma-phone', className]} data-model={model} data-scroll={scroll ? '' : undefined} data-theme={theme === 'auto' ? undefined : theme} style={vars} {...rest}>
|
|
33
|
+
<div class="ma-phone__body">
|
|
34
|
+
<div class="ma-phone__screen">
|
|
35
|
+
<div class="ma-phone__content">
|
|
36
|
+
{src && typeof src === 'string' && <img src={src} alt={alt} loading="lazy" decoding="async" />}
|
|
37
|
+
{src && typeof src !== 'string' && <Image src={src} alt={alt} />}
|
|
38
|
+
<slot />
|
|
39
|
+
</div>
|
|
40
|
+
<span class="ma-phone__cutout" aria-hidden="true"></span>
|
|
41
|
+
<span class="ma-phone__home" aria-hidden="true"></span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<style is:global>
|
|
47
|
+
@keyframes ma-phone-drift {
|
|
48
|
+
to {
|
|
49
|
+
translate: 0 min(0px, calc(100cqh - 100%));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
@layer components {
|
|
53
|
+
:where(.ma-phone) {
|
|
54
|
+
/* the container: its own cqw units belong to its descendants, so the bezel is a child (the body), not this box */
|
|
55
|
+
container-type: inline-size;
|
|
56
|
+
display: grid;
|
|
57
|
+
inline-size: var(--ma-phone-w, 18rem);
|
|
58
|
+
max-inline-size: 100%;
|
|
59
|
+
aspect-ratio: 9 / 19.5;
|
|
60
|
+
/* an aspect-ratio box has a content-based automatic minimum height: WebKit let the screen's content stretch the phone past its ratio */
|
|
61
|
+
min-block-size: 0;
|
|
62
|
+
}
|
|
63
|
+
:where(.ma-phone__body) {
|
|
64
|
+
/* the same tokens BrowserFrame uses for its chrome: edge, panel, glow; every length in cqw of the phone */
|
|
65
|
+
box-sizing: border-box;
|
|
66
|
+
display: grid;
|
|
67
|
+
min-block-size: 0;
|
|
68
|
+
padding: 3cqw;
|
|
69
|
+
border: 1px solid var(--ma-edge);
|
|
70
|
+
border-radius: 15cqw;
|
|
71
|
+
background: color-mix(in srgb, var(--ma-ink) 92%, var(--ma-panel));
|
|
72
|
+
box-shadow:
|
|
73
|
+
inset 0 0 0 1px color-mix(in srgb, var(--ma-panel) 25%, transparent),
|
|
74
|
+
0 1px 2px var(--ma-glow),
|
|
75
|
+
0 24px 48px -24px rgb(0 0 0 / 0.5);
|
|
76
|
+
}
|
|
77
|
+
:where(.ma-phone[data-model='punch']) {
|
|
78
|
+
aspect-ratio: 9 / 20;
|
|
79
|
+
}
|
|
80
|
+
:where(.ma-phone[data-theme='dark']) {
|
|
81
|
+
color-scheme: dark;
|
|
82
|
+
--ma-shine: rgb(255 255 255 / 0.32);
|
|
83
|
+
--ma-scrim: rgb(10 10 12 / 0.72);
|
|
84
|
+
--ma-edge: rgb(255 255 255 / 0.1);
|
|
85
|
+
--ma-ink: rgb(255 255 255 / 0.92);
|
|
86
|
+
--ma-panel: rgb(24 24 27 / 0.94);
|
|
87
|
+
--ma-glow: rgb(255 255 255 / 0.14);
|
|
88
|
+
--ma-dark: 1;
|
|
89
|
+
}
|
|
90
|
+
:where(.ma-phone[data-theme='light']) {
|
|
91
|
+
color-scheme: light;
|
|
92
|
+
--ma-shine: rgb(255 255 255 / 0.85);
|
|
93
|
+
--ma-scrim: rgb(255 255 255 / 0.72);
|
|
94
|
+
--ma-edge: rgb(0 0 0 / 0.08);
|
|
95
|
+
--ma-ink: rgb(0 0 0 / 0.9);
|
|
96
|
+
--ma-panel: rgb(255 255 255 / 0.92);
|
|
97
|
+
--ma-glow: rgb(0 0 0 / 0.1);
|
|
98
|
+
--ma-dark: 0;
|
|
99
|
+
}
|
|
100
|
+
:where(.ma-phone__screen) {
|
|
101
|
+
position: relative;
|
|
102
|
+
container-type: size;
|
|
103
|
+
min-block-size: 0;
|
|
104
|
+
border-radius: 12cqw;
|
|
105
|
+
background: var(--ma-panel);
|
|
106
|
+
color: var(--ma-ink);
|
|
107
|
+
overflow: clip;
|
|
108
|
+
}
|
|
109
|
+
:where(.ma-phone__content) {
|
|
110
|
+
min-block-size: 100%;
|
|
111
|
+
}
|
|
112
|
+
:where(.ma-phone__content > img) {
|
|
113
|
+
display: block;
|
|
114
|
+
inline-size: 100%;
|
|
115
|
+
block-size: auto;
|
|
116
|
+
}
|
|
117
|
+
:where(.ma-phone__cutout) {
|
|
118
|
+
position: absolute;
|
|
119
|
+
inset-block-start: 4cqw;
|
|
120
|
+
inset-inline-start: 50%;
|
|
121
|
+
inline-size: 30cqw;
|
|
122
|
+
block-size: 8cqw;
|
|
123
|
+
translate: -50% 0;
|
|
124
|
+
border-radius: 999px;
|
|
125
|
+
background: #000;
|
|
126
|
+
}
|
|
127
|
+
:where(.ma-phone[data-model='notch'] .ma-phone__cutout) {
|
|
128
|
+
inset-block-start: 0;
|
|
129
|
+
inline-size: 52cqw;
|
|
130
|
+
block-size: 8.5cqw;
|
|
131
|
+
border-radius: 0 0 6cqw 6cqw;
|
|
132
|
+
}
|
|
133
|
+
:where(.ma-phone[data-model='punch'] .ma-phone__cutout) {
|
|
134
|
+
inline-size: 5.5cqw;
|
|
135
|
+
block-size: 5.5cqw;
|
|
136
|
+
border-radius: 50%;
|
|
137
|
+
}
|
|
138
|
+
:where(.ma-phone__home) {
|
|
139
|
+
position: absolute;
|
|
140
|
+
inset-block-end: 2.5cqw;
|
|
141
|
+
inset-inline-start: 50%;
|
|
142
|
+
inline-size: 36cqw;
|
|
143
|
+
block-size: 1.3cqw;
|
|
144
|
+
translate: -50% 0;
|
|
145
|
+
border-radius: 999px;
|
|
146
|
+
background: color-mix(in srgb, var(--ma-ink) 65%, transparent);
|
|
147
|
+
}
|
|
148
|
+
/* scroll: content taller than the screen drifts up and back; the pointer, or focus inside, holds it */
|
|
149
|
+
:where(.ma-phone[data-scroll] .ma-phone__content) {
|
|
150
|
+
animation: ma-phone-drift calc(var(--ma-dur-ambient) * 6) var(--ma-ease-in-out) infinite alternate;
|
|
151
|
+
}
|
|
152
|
+
:where(.ma-phone[data-scroll]:is(:hover, :focus-within) .ma-phone__content) {
|
|
153
|
+
animation-play-state: paused;
|
|
154
|
+
}
|
|
155
|
+
@media (prefers-reduced-motion: reduce) {
|
|
156
|
+
:where(.ma-phone[data-scroll] .ma-phone__content) {
|
|
157
|
+
animation: none;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'PhoneFrame',
|
|
5
|
+
slug: 'phone-frame',
|
|
6
|
+
title: 'Phone Frame',
|
|
7
|
+
description: 'A generic phone frame around a screenshot or any content, with three cut-outs (island, notch, punch), a home bar, and no logo, buttons or brand. The chrome scales with the frame\'s width through container query units and shares BrowserFrame\'s tokens; scroll drifts content taller than the screen up and back on a translate keyframe, paused under the pointer. Zero JavaScript.',
|
|
8
|
+
summary: 'A generic phone mockup around a screenshot, with an island, a notch or a punch hole, and content that scrolls.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['always'],
|
|
12
|
+
support: { baseline: 'widely', needs: ['container-units'] },
|
|
13
|
+
readout: '<PhoneFrame model="island" src={shot} scroll>',
|
|
14
|
+
usage: `---\nimport shot from '../assets/app.png';\n---\n<PhoneFrame model="island" src={shot} alt="The app's home screen" scroll />\n\n<!-- any content, pinned dark -->\n<PhoneFrame model="punch" theme="dark" width="14rem">\n <div class="p-6">…</div>\n</PhoneFrame>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'model', type: "'island' | 'notch' | 'punch'", default: "'island'", description: 'The cut-out: a pill, a notch at the top edge, or a dot. Generic shapes, no brand.' },
|
|
17
|
+
{ name: 'src', type: 'ImageMetadata | string', description: 'A screenshot: an imported image (optimised by Astro) or a URL. Or fill the slot.' },
|
|
18
|
+
{ name: 'alt', type: 'string', default: "''", description: 'Alt text for the screenshot.' },
|
|
19
|
+
{ name: 'scroll', type: 'boolean', default: 'false', description: 'Drift content taller than the screen up and back (six ambient durations per leg), paused on hover and focus.' },
|
|
20
|
+
{ name: 'theme', type: "'light' | 'dark' | 'auto'", default: "'auto'", description: 'Pin the base tokens to one theme, or follow the page. The theme.css tokens follow the page; add data-ma-theme="dark" as well to re-derive them inside the frame.' },
|
|
21
|
+
{ name: 'width', type: 'string', default: "'18rem'", description: 'Frame width; the height follows the model\'s ratio (9:19.5, punch 9:20).' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'The content stands at the top; nothing drifts.',
|
|
24
|
+
clientRouter: 'Static; nothing to bind.',
|
|
25
|
+
craft: [
|
|
26
|
+
'Every length is in cqw of the frame (bezel, radius, cut-out, home bar), so a 14 rem phone and a 24 rem phone are the same drawing, the BrowserFrame technique.',
|
|
27
|
+
'The drift is one translate keyframe to min(0, 100cqh − 100%): 100cqh is the screen (a size container), 100% the content\'s own height, so content shorter than the screen never moves and longer content ends exactly at its bottom. Nothing is measured.',
|
|
28
|
+
'The frame is generic on purpose: three neutral cut-out shapes, no logo, no button silhouettes. It draws a phone, not an imitation of one product.',
|
|
29
|
+
'The drift pauses under the pointer and while anything inside has focus, so a reader can look at a screen, or tab through real content in the slot, without chasing it.',
|
|
30
|
+
],
|
|
31
|
+
related: ['browser-frame', 'dynamic-island', 'image-compare'],
|
|
32
|
+
intents: ['phone mockup', 'iphone frame css', 'device frame screenshot', 'mobile mockup scrolling', 'app screenshot frame', 'phone frame no brand'],
|
|
33
|
+
replaces: ['Iphone15Pro (Magic UI)', 'DeviceFrame (Cult UI)', 'Mockup (Tailwind Plus)'],
|
|
34
|
+
fixture: { props: { model: 'island', width: '16rem' }, slot: '<p>Fixture content for PhoneFrame</p>', expect: 'Fixture content for PhoneFrame' },
|
|
35
|
+
} satisfies ComponentMeta;
|