@3xhaust/oh-my-design 0.16.1
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.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- package/src/skills/omd-ultradesign/SKILL.md +521 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Marquee
|
|
2
|
+
|
|
3
|
+
A marquee scrolls a sequence of items continuously across the viewport — a brand phrase
|
|
4
|
+
repeated, a list of client names, a scrolling ticker. The motion is constant and
|
|
5
|
+
low-velocity; it creates peripheral activity that reinforces the concept without
|
|
6
|
+
competing with primary content.
|
|
7
|
+
|
|
8
|
+
## When it earns its place / When it does not
|
|
9
|
+
|
|
10
|
+
Condition: the marquee carries a message that is part of the concept — a repeated claim
|
|
11
|
+
whose repetition is the point ("Fast. Fast. Fast."), a rhythmic brand phrase, a roster
|
|
12
|
+
of names whose accumulation communicates scale. The content loops because looping is
|
|
13
|
+
meaningful, not because the list is too long for a static layout.
|
|
14
|
+
|
|
15
|
+
Condition against: navigation areas and any context where the user is reading or
|
|
16
|
+
interacting with nearby content. Peripheral motion competes with focal attention —
|
|
17
|
+
`expressive.md`: "A marquee adjacent to a form field splits attention between peripheral
|
|
18
|
+
motion and the focal task — vestibular disruption for users with motion sensitivity."
|
|
19
|
+
Also: a marquee used because the designer ran out of content. If the marquee stops and
|
|
20
|
+
the page still makes sense, the marquee was decoration. If the list it contains would
|
|
21
|
+
fit in a single static row, it should be a static row.
|
|
22
|
+
|
|
23
|
+
The technique has a ceiling: one marquee per page. Two marquees, especially running at
|
|
24
|
+
different speeds or in opposite directions, produce a kinetic noise floor that
|
|
25
|
+
undermines everything around it.
|
|
26
|
+
|
|
27
|
+
## Parameters
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
:root {
|
|
31
|
+
/* Time for one full loop at the content's natural width. Longer = slower.
|
|
32
|
+
A comfortable reading velocity: viewers can parse items at ~80px/s.
|
|
33
|
+
For a 1200px-wide content block: 1200 / 80 ≈ 15 seconds. */
|
|
34
|
+
--marquee-duration: 18s;
|
|
35
|
+
|
|
36
|
+
/* Gap between each item in the marquee track. */
|
|
37
|
+
--marquee-gap: 4rem;
|
|
38
|
+
|
|
39
|
+
/* Direction: normal = left to right; reverse = right to left. */
|
|
40
|
+
--marquee-direction: normal;
|
|
41
|
+
|
|
42
|
+
/* Pause on hover: enables on interactive marquees, disables on ambient ones. */
|
|
43
|
+
--marquee-play-state: running;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Implementation
|
|
48
|
+
|
|
49
|
+
The technique: duplicate the content so the seam is never visible. The duplicate starts
|
|
50
|
+
immediately after the first copy; when the first copy has scrolled fully off-screen, the
|
|
51
|
+
duplicate has arrived at the start position and the loop is seamless.
|
|
52
|
+
|
|
53
|
+
```html
|
|
54
|
+
<div class="marquee" aria-hidden="true">
|
|
55
|
+
<div class="marquee-track">
|
|
56
|
+
<ul class="marquee-content" aria-label="Client roster">
|
|
57
|
+
<li>Acme</li>
|
|
58
|
+
<li>Globex</li>
|
|
59
|
+
<li>Initech</li>
|
|
60
|
+
<!-- … items … -->
|
|
61
|
+
</ul>
|
|
62
|
+
<!-- Duplicate for seamless loop. Aria-hidden on the duplicate. -->
|
|
63
|
+
<ul class="marquee-content" aria-hidden="true">
|
|
64
|
+
<li>Acme</li>
|
|
65
|
+
<li>Globex</li>
|
|
66
|
+
<li>Initech</li>
|
|
67
|
+
</ul>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```css
|
|
73
|
+
.marquee {
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
white-space: nowrap;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.marquee-track {
|
|
79
|
+
display: flex;
|
|
80
|
+
width: max-content;
|
|
81
|
+
animation: marquee-scroll var(--marquee-duration) linear infinite;
|
|
82
|
+
animation-direction: var(--marquee-direction);
|
|
83
|
+
animation-play-state: var(--marquee-play-state);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Pause on hover (interactive marquees only — remove for ambient ones) */
|
|
87
|
+
.marquee:hover .marquee-track {
|
|
88
|
+
animation-play-state: paused;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.marquee-content {
|
|
92
|
+
display: flex;
|
|
93
|
+
gap: var(--marquee-gap);
|
|
94
|
+
list-style: none;
|
|
95
|
+
padding: 0;
|
|
96
|
+
margin: 0;
|
|
97
|
+
/* Second copy immediately follows the first — no gap between copies needed
|
|
98
|
+
because the animation moves the entire track exactly one copy-width. */
|
|
99
|
+
padding-inline-end: var(--marquee-gap);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@keyframes marquee-scroll {
|
|
103
|
+
from { transform: translateX(0); }
|
|
104
|
+
to { transform: translateX(-50%); }
|
|
105
|
+
/* -50%: moves the track by exactly one copy's width, bringing the duplicate
|
|
106
|
+
to the start position. The loop is seamless. */
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## React
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
import { useRef } from 'react';
|
|
114
|
+
import { motion } from 'framer-motion';
|
|
115
|
+
|
|
116
|
+
function Marquee({
|
|
117
|
+
items,
|
|
118
|
+
duration = 18,
|
|
119
|
+
direction = 1, // 1 = left-to-right, -1 = right-to-left
|
|
120
|
+
pauseOnHover = false,
|
|
121
|
+
}: {
|
|
122
|
+
items: string[];
|
|
123
|
+
duration?: number;
|
|
124
|
+
direction?: 1 | -1;
|
|
125
|
+
pauseOnHover?: boolean;
|
|
126
|
+
}) {
|
|
127
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
128
|
+
|
|
129
|
+
// Duplicate items for seamless loop.
|
|
130
|
+
const allItems = [...items, ...items];
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
|
134
|
+
<motion.div
|
|
135
|
+
ref={trackRef}
|
|
136
|
+
style={{ display: 'flex', width: 'max-content' }}
|
|
137
|
+
animate={{ x: direction === 1 ? [0, '-50%'] : ['-50%', 0] }}
|
|
138
|
+
transition={{
|
|
139
|
+
duration,
|
|
140
|
+
ease: 'linear',
|
|
141
|
+
repeat: Infinity,
|
|
142
|
+
repeatType: 'loop',
|
|
143
|
+
}}
|
|
144
|
+
whileHover={pauseOnHover ? { animationPlayState: 'paused' } : undefined}
|
|
145
|
+
>
|
|
146
|
+
{allItems.map((item, i) => (
|
|
147
|
+
<span
|
|
148
|
+
key={i}
|
|
149
|
+
style={{ paddingInlineEnd: 'var(--marquee-gap, 4rem)' }}
|
|
150
|
+
aria-hidden={i >= items.length ? 'true' : undefined}
|
|
151
|
+
>
|
|
152
|
+
{item}
|
|
153
|
+
</span>
|
|
154
|
+
))}
|
|
155
|
+
</motion.div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Reduced-motion variant
|
|
162
|
+
|
|
163
|
+
In reduced-motion context, the marquee must stop. A moving marquee for users with
|
|
164
|
+
vestibular disorders can trigger nausea even at low velocity. The content is shown as a
|
|
165
|
+
static, wrapped list — the information is preserved, the motion is removed.
|
|
166
|
+
|
|
167
|
+
```css
|
|
168
|
+
@media (prefers-reduced-motion: reduce) {
|
|
169
|
+
.marquee-track {
|
|
170
|
+
animation: none;
|
|
171
|
+
width: auto;
|
|
172
|
+
flex-wrap: wrap;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Hide the duplicate copy — only needed for the loop illusion. */
|
|
176
|
+
.marquee-content:last-child {
|
|
177
|
+
display: none;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.marquee {
|
|
181
|
+
overflow: visible;
|
|
182
|
+
white-space: normal;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Performance note
|
|
188
|
+
|
|
189
|
+
`transform: translateX()` is a compositor-only property — the browser moves the layer
|
|
190
|
+
on the GPU without touching layout. The animation runs at 60fps on all reasonable
|
|
191
|
+
hardware with no main-thread involvement.
|
|
192
|
+
|
|
193
|
+
The `animation-duration` drives perceived velocity, not the actual pixel distance per
|
|
194
|
+
frame. As items are added to the marquee, the content becomes wider; the same duration
|
|
195
|
+
now produces a faster apparent motion (more pixels per second). Recalculate the duration
|
|
196
|
+
when the content changes: `duration = contentWidth / targetPixelsPerSecond`. A JavaScript
|
|
197
|
+
observer on the marquee-content can handle this dynamically.
|
|
198
|
+
|
|
199
|
+
Do not use `marquee-gap` so large that the duplicate's arrival is visible before the
|
|
200
|
+
original departs — there should be no visible gap between copies at the seam. The
|
|
201
|
+
`padding-inline-end` on `.marquee-content` absorbs this gap, but verify on your actual
|
|
202
|
+
content widths.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Number counter
|
|
2
|
+
|
|
3
|
+
A number counter animates a numeric value from a starting point to its final value —
|
|
4
|
+
`0 → 4,200` or `12% → 98%` — when the element enters the viewport. The motion is
|
|
5
|
+
eased so the count accelerates at first and decelerates as it approaches the target,
|
|
6
|
+
reading as accumulation rather than mechanical increment.
|
|
7
|
+
|
|
8
|
+
## When it earns its place / When it does not
|
|
9
|
+
|
|
10
|
+
Condition: a stat whose magnitude is the point — the scale of a number communicates
|
|
11
|
+
something the number alone at rest does not. "4,200 customers" has more weight arriving
|
|
12
|
+
from zero than landing at rest. The counter is appropriate when the count duration
|
|
13
|
+
aligns with reading speed: the user sees the number rising and understands its scale
|
|
14
|
+
before it settles.
|
|
15
|
+
|
|
16
|
+
Condition against: tables, data grids, or any context where numbers change due to
|
|
17
|
+
user interaction or live data. Animating numbers that the user is trying to read or
|
|
18
|
+
compare makes reading impossible. Also: very small numbers — `0 → 5` counts through
|
|
19
|
+
all six values visibly; this reads as a spinner rather than an accumulation. Use a
|
|
20
|
+
simple fade-in for numbers under 10. Also: numbers that update repeatedly — a live
|
|
21
|
+
dashboard counter that re-animates on each update is a usability failure.
|
|
22
|
+
|
|
23
|
+
The counter must complete quickly enough that the user does not wait for it: 600–1000ms
|
|
24
|
+
is the legible range. Beyond 1000ms the user has moved on and the animation resolves
|
|
25
|
+
for no one.
|
|
26
|
+
|
|
27
|
+
## Parameters
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
:root {
|
|
31
|
+
/* Total duration of the count animation. */
|
|
32
|
+
--counter-duration: 800ms;
|
|
33
|
+
|
|
34
|
+
/* The easing is applied to the animation progress, not to a CSS property.
|
|
35
|
+
Described here for documentation; actual implementation is in JS via a
|
|
36
|
+
cubic-bezier computation over the progress value. */
|
|
37
|
+
--counter-ease: ease-out-quint; /* see easing.md for the cubic-bezier values */
|
|
38
|
+
|
|
39
|
+
/* Optional: hold before counting starts (e.g., after the element fades in). */
|
|
40
|
+
--counter-delay: 80ms;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Implementation
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<div class="stat-block">
|
|
48
|
+
<span class="counter" data-target="4200" data-prefix="" data-suffix=" customers">0</span>
|
|
49
|
+
</div>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```css
|
|
53
|
+
/* The counter fades in as counting begins. The value animation is JS-driven
|
|
54
|
+
(requestAnimationFrame), but the entrance uses opacity — a compositor-only
|
|
55
|
+
property — so both axes animate without touching layout. */
|
|
56
|
+
.counter {
|
|
57
|
+
font-variant-numeric: tabular-nums;
|
|
58
|
+
opacity: 0;
|
|
59
|
+
transition: opacity 180ms var(--ease-out-quint, ease-out);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.counter.is-counting {
|
|
63
|
+
opacity: 1;
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// Easing function: ease-out-quint approximation over [0..1].
|
|
69
|
+
// Equivalent to --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1).
|
|
70
|
+
function easeOutQuint(t) {
|
|
71
|
+
return 1 - Math.pow(1 - t, 5);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function animateCounter(el) {
|
|
75
|
+
const target = parseFloat(el.dataset.target ?? '0');
|
|
76
|
+
const prefix = el.dataset.prefix ?? '';
|
|
77
|
+
const suffix = el.dataset.suffix ?? '';
|
|
78
|
+
const start = parseFloat(el.dataset.from ?? '0');
|
|
79
|
+
const duration = parseFloat(
|
|
80
|
+
getComputedStyle(document.documentElement).getPropertyValue('--counter-duration')
|
|
81
|
+
) || 800;
|
|
82
|
+
const delay = parseFloat(
|
|
83
|
+
getComputedStyle(document.documentElement).getPropertyValue('--counter-delay')
|
|
84
|
+
) || 0;
|
|
85
|
+
|
|
86
|
+
// Format numbers with locale-aware separators.
|
|
87
|
+
function format(n) {
|
|
88
|
+
return prefix + Math.round(n).toLocaleString() + suffix;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fade in as counting begins.
|
|
92
|
+
el.classList.add('is-counting');
|
|
93
|
+
|
|
94
|
+
let startTime = null;
|
|
95
|
+
|
|
96
|
+
function frame(now) {
|
|
97
|
+
if (!startTime) startTime = now + delay;
|
|
98
|
+
const elapsed = Math.max(0, now - startTime);
|
|
99
|
+
const progress = Math.min(1, elapsed / duration);
|
|
100
|
+
const eased = easeOutQuint(progress);
|
|
101
|
+
el.textContent = format(start + (target - start) * eased);
|
|
102
|
+
|
|
103
|
+
if (progress < 1) {
|
|
104
|
+
requestAnimationFrame(frame);
|
|
105
|
+
} else {
|
|
106
|
+
el.textContent = format(target); // ensure exact final value
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
requestAnimationFrame(frame);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Trigger on intersection — count fires once when the stat enters the viewport.
|
|
114
|
+
const observer = new IntersectionObserver(
|
|
115
|
+
(entries) => {
|
|
116
|
+
entries.forEach((entry) => {
|
|
117
|
+
if (entry.isIntersecting) {
|
|
118
|
+
animateCounter(entry.target);
|
|
119
|
+
observer.unobserve(entry.target);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
{ threshold: 0.5 }
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
document.querySelectorAll('.counter').forEach((el) => observer.observe(el));
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## React
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
import { useEffect, useRef, useState } from 'react';
|
|
133
|
+
import { useInView } from 'framer-motion';
|
|
134
|
+
|
|
135
|
+
function easeOutQuint(t: number) {
|
|
136
|
+
return 1 - Math.pow(1 - t, 5);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function useCounter(target: number, duration = 800, enabled = true) {
|
|
140
|
+
const [value, setValue] = useState(0);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!enabled) {
|
|
144
|
+
setValue(target);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let startTime: number | null = null;
|
|
149
|
+
let rafId: number;
|
|
150
|
+
|
|
151
|
+
function frame(now: number) {
|
|
152
|
+
if (!startTime) startTime = now;
|
|
153
|
+
const progress = Math.min(1, (now - startTime) / duration);
|
|
154
|
+
setValue(Math.round(target * easeOutQuint(progress)));
|
|
155
|
+
if (progress < 1) {
|
|
156
|
+
rafId = requestAnimationFrame(frame);
|
|
157
|
+
} else {
|
|
158
|
+
setValue(target);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
rafId = requestAnimationFrame(frame);
|
|
163
|
+
return () => cancelAnimationFrame(rafId);
|
|
164
|
+
}, [target, duration, enabled]);
|
|
165
|
+
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function StatCounter({
|
|
170
|
+
target,
|
|
171
|
+
prefix = '',
|
|
172
|
+
suffix = '',
|
|
173
|
+
duration = 800,
|
|
174
|
+
}: {
|
|
175
|
+
target: number;
|
|
176
|
+
prefix?: string;
|
|
177
|
+
suffix?: string;
|
|
178
|
+
duration?: number;
|
|
179
|
+
}) {
|
|
180
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
181
|
+
const isInView = useInView(ref, { once: true, margin: '-20% 0px' });
|
|
182
|
+
const value = useCounter(target, duration, isInView);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<span ref={ref} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
186
|
+
{prefix}{value.toLocaleString()}{suffix}
|
|
187
|
+
</span>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Reduced-motion variant
|
|
193
|
+
|
|
194
|
+
In reduced-motion context, the final number is shown immediately — no counting
|
|
195
|
+
animation. The value is present and readable from the moment the element enters
|
|
196
|
+
the viewport.
|
|
197
|
+
|
|
198
|
+
```css
|
|
199
|
+
/* No CSS to add — the counter is entirely JS-driven.
|
|
200
|
+
The JS check below handles the reduced-motion case. */
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
205
|
+
|
|
206
|
+
if (prefersReduced) {
|
|
207
|
+
// Show final values immediately without animation.
|
|
208
|
+
document.querySelectorAll('.counter').forEach((el) => {
|
|
209
|
+
const target = el.dataset.target ?? '0';
|
|
210
|
+
const prefix = el.dataset.prefix ?? '';
|
|
211
|
+
const suffix = el.dataset.suffix ?? '';
|
|
212
|
+
el.textContent = prefix + parseFloat(target).toLocaleString() + suffix;
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
// ... observer setup
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
In the React hook, `useCounter` already accepts an `enabled` flag — pass `false` when
|
|
220
|
+
`prefers-reduced-motion` is active:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
224
|
+
const value = useCounter(target, duration, isInView && !prefersReduced);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Performance note
|
|
228
|
+
|
|
229
|
+
`requestAnimationFrame` is the correct animation driver for JS-computed value changes —
|
|
230
|
+
it aligns updates to the browser's paint schedule and is automatically throttled on
|
|
231
|
+
background tabs. Do not use `setInterval` for counter animation; it runs off the paint
|
|
232
|
+
schedule and produces visible frame drops.
|
|
233
|
+
|
|
234
|
+
The counter updates `textContent` — a DOM write — on every frame. This triggers a
|
|
235
|
+
text-specific paint on the element but not a layout reflow, provided the element's
|
|
236
|
+
dimensions are stable. `font-variant-numeric: tabular-nums` is required for this:
|
|
237
|
+
proportional numerals change width on each digit change, triggering layout reflow on
|
|
238
|
+
every frame. Tabular numerals are fixed-width; the element's width is constant.
|
|
239
|
+
|
|
240
|
+
For multiple counters on one page, each runs its own `requestAnimationFrame` loop.
|
|
241
|
+
The browser batches these into one paint per frame; there is no penalty for multiple
|
|
242
|
+
concurrent loops.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Page loader
|
|
2
|
+
|
|
3
|
+
A page loader covers the initial render while assets load, then exits — revealing the
|
|
4
|
+
page underneath as an entrance rather than a progressive paint. It is the opening
|
|
5
|
+
sentence of the experience: the palette commits, the type register declares itself, the
|
|
6
|
+
motion vocabulary introduces itself. Done well, the loader adds nothing to the perceived
|
|
7
|
+
wait time; done badly, it is the first thing the user resents.
|
|
8
|
+
|
|
9
|
+
## When it earns its place / When it does not
|
|
10
|
+
|
|
11
|
+
Condition: a showpiece page where the entrance is part of the experience — a campaign
|
|
12
|
+
microsite, an agency portfolio, a product launch with a single CTA. The loader must do
|
|
13
|
+
something with its time: commit the palette, reveal the type register, establish that
|
|
14
|
+
this is not a default page. `expressive.md`: "What the entrance must do in those
|
|
15
|
+
seconds: commit the palette, declare the type register, establish the motion vocabulary.
|
|
16
|
+
A loader that is only a spinner followed by a page reveal has wasted the three seconds
|
|
17
|
+
it consumed."
|
|
18
|
+
|
|
19
|
+
Condition against: product UI, tools, documentation, any page the user came to operate
|
|
20
|
+
rather than experience. A dashboard that holds the user behind a loader for 300ms on
|
|
21
|
+
every visit is spending attention budget the user did not agree to spend. Also: any page
|
|
22
|
+
where asset load time is variable and potentially fast — a loader on a sub-100ms page
|
|
23
|
+
load adds the only delay the user experiences.
|
|
24
|
+
|
|
25
|
+
The hard rule: the loader must exit no later than 300ms after the `load` event fires.
|
|
26
|
+
`expressive.md` and `core/theory/motion.md` both state the outer bound: three seconds
|
|
27
|
+
total, under one second as the performance target. A loader that runs independent of
|
|
28
|
+
asset load time — a fixed-duration animation that plays regardless — is spending the
|
|
29
|
+
user's attention against an arbitrary clock. Tie the exit to the `load` event with a
|
|
30
|
+
minimum hold of 300ms (so the entrance reads as intentional, not as a flash) and a
|
|
31
|
+
maximum of 3000ms (absolute outer bound).
|
|
32
|
+
|
|
33
|
+
## Parameters
|
|
34
|
+
|
|
35
|
+
```css
|
|
36
|
+
:root {
|
|
37
|
+
/* Minimum time the loader holds even if the page loads faster.
|
|
38
|
+
Below this, the loader reads as a flash rather than an entrance. */
|
|
39
|
+
--loader-hold-min: 300ms;
|
|
40
|
+
|
|
41
|
+
/* Maximum time before the loader forces exit regardless of load state. */
|
|
42
|
+
--loader-hold-max: 2400ms;
|
|
43
|
+
|
|
44
|
+
/* Duration of the loader's exit animation. */
|
|
45
|
+
--loader-exit-duration: 480ms;
|
|
46
|
+
|
|
47
|
+
/* Easing for the exit. ease-in reads as deliberate departure. */
|
|
48
|
+
--loader-exit-ease: var(--ease-in-expo);
|
|
49
|
+
|
|
50
|
+
/* Duration for the page's entrance after the loader exits. */
|
|
51
|
+
--loader-page-enter-duration: 360ms;
|
|
52
|
+
--loader-page-enter-ease: var(--ease-out-quint);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Implementation
|
|
57
|
+
|
|
58
|
+
```html
|
|
59
|
+
<!-- Loader sits above the page content. -->
|
|
60
|
+
<div class="page-loader" id="loader" aria-live="polite" aria-label="Loading">
|
|
61
|
+
<div class="loader-inner">
|
|
62
|
+
<!-- Commit the brand: a logotype, a single word, a colour field.
|
|
63
|
+
Not a spinner — a statement. -->
|
|
64
|
+
<span class="loader-wordmark">Studio</span>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<main class="page-content" id="page" style="opacity: 0;">
|
|
69
|
+
<!-- page content -->
|
|
70
|
+
</main>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
.page-loader {
|
|
75
|
+
position: fixed;
|
|
76
|
+
inset: 0;
|
|
77
|
+
z-index: 1000;
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
justify-content: center;
|
|
81
|
+
background-color: var(--color-bg-dark, #0a0a0a);
|
|
82
|
+
color: var(--color-text-dark, #fff);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.page-loader.is-exiting {
|
|
86
|
+
animation: loader-exit var(--loader-exit-duration) var(--loader-exit-ease) forwards;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@keyframes loader-exit {
|
|
90
|
+
to {
|
|
91
|
+
opacity: 0;
|
|
92
|
+
/* Slide up or clip — choose one and wire it to the concept. */
|
|
93
|
+
transform: translateY(-100%);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.page-content {
|
|
98
|
+
transition: opacity var(--loader-page-enter-duration) var(--loader-page-enter-ease);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.page-content.is-visible {
|
|
102
|
+
opacity: 1;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
(function () {
|
|
108
|
+
const loader = document.getElementById('loader');
|
|
109
|
+
const page = document.getElementById('page');
|
|
110
|
+
if (!loader || !page) return;
|
|
111
|
+
|
|
112
|
+
// Read token values from :root.
|
|
113
|
+
const root = document.documentElement;
|
|
114
|
+
const minHold = parseFloat(getComputedStyle(root).getPropertyValue('--loader-hold-min')) || 300;
|
|
115
|
+
const maxHold = parseFloat(getComputedStyle(root).getPropertyValue('--loader-hold-max')) || 2400;
|
|
116
|
+
const exitDuration = parseFloat(getComputedStyle(root).getPropertyValue('--loader-exit-duration')) || 480;
|
|
117
|
+
|
|
118
|
+
let loadFired = false;
|
|
119
|
+
let startTime = Date.now();
|
|
120
|
+
|
|
121
|
+
function exit() {
|
|
122
|
+
loader.classList.add('is-exiting');
|
|
123
|
+
loader.setAttribute('aria-hidden', 'true');
|
|
124
|
+
page.classList.add('is-visible');
|
|
125
|
+
setTimeout(() => loader.remove(), exitDuration);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function maybeExit() {
|
|
129
|
+
const elapsed = Date.now() - startTime;
|
|
130
|
+
const remaining = Math.max(0, minHold - elapsed);
|
|
131
|
+
setTimeout(exit, remaining);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Absolute outer bound — never hold longer than --loader-hold-max.
|
|
135
|
+
const maxTimer = setTimeout(exit, maxHold);
|
|
136
|
+
|
|
137
|
+
window.addEventListener('load', () => {
|
|
138
|
+
clearTimeout(maxTimer);
|
|
139
|
+
maybeExit();
|
|
140
|
+
});
|
|
141
|
+
})();
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## React
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
148
|
+
import { useState, useEffect } from 'react';
|
|
149
|
+
|
|
150
|
+
function PageLoader({ minHold = 300 }: { minHold?: number }) {
|
|
151
|
+
const [visible, setVisible] = useState(true);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
const start = Date.now();
|
|
155
|
+
|
|
156
|
+
function handleLoad() {
|
|
157
|
+
const elapsed = Date.now() - start;
|
|
158
|
+
const delay = Math.max(0, minHold - elapsed);
|
|
159
|
+
setTimeout(() => setVisible(false), delay);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (document.readyState === 'complete') {
|
|
163
|
+
handleLoad();
|
|
164
|
+
} else {
|
|
165
|
+
window.addEventListener('load', handleLoad, { once: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Absolute maximum — never hold beyond 2.4s
|
|
169
|
+
const timeout = setTimeout(() => setVisible(false), 2400);
|
|
170
|
+
return () => clearTimeout(timeout);
|
|
171
|
+
}, [minHold]);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<AnimatePresence>
|
|
175
|
+
{visible && (
|
|
176
|
+
<motion.div
|
|
177
|
+
key="loader"
|
|
178
|
+
style={{ position: 'fixed', inset: 0, zIndex: 1000,
|
|
179
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
180
|
+
backgroundColor: 'var(--color-bg-dark)' }}
|
|
181
|
+
exit={{
|
|
182
|
+
opacity: 0,
|
|
183
|
+
y: '-100%',
|
|
184
|
+
transition: { duration: 0.48, ease: [0.7, 0, 0.84, 0] }, // ease-in-expo
|
|
185
|
+
}}
|
|
186
|
+
aria-label="Loading"
|
|
187
|
+
role="status"
|
|
188
|
+
>
|
|
189
|
+
<span style={{ color: 'var(--color-text-dark)' }}>Studio</span>
|
|
190
|
+
</motion.div>
|
|
191
|
+
)}
|
|
192
|
+
</AnimatePresence>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Reduced-motion variant
|
|
198
|
+
|
|
199
|
+
In reduced-motion context, the loader exits immediately without an animated transition.
|
|
200
|
+
The page is visible from the start — no hold, no entrance animation.
|
|
201
|
+
|
|
202
|
+
```css
|
|
203
|
+
@media (prefers-reduced-motion: reduce) {
|
|
204
|
+
.page-loader {
|
|
205
|
+
display: none;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.page-content {
|
|
209
|
+
opacity: 1;
|
|
210
|
+
transition: none;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
In the JS, check before adding any classes:
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
219
|
+
if (prefersReduced) {
|
|
220
|
+
loader.remove();
|
|
221
|
+
page.style.opacity = '1';
|
|
222
|
+
return; // skip the entire loader
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Performance note
|
|
227
|
+
|
|
228
|
+
The loader is `position: fixed`, covering the entire viewport. While it is visible, the
|
|
229
|
+
content beneath it does not need to paint — the browser can defer content paint until
|
|
230
|
+
the loader exits. In practice this is a performance advantage: the actual page content
|
|
231
|
+
renders in parallel with the loader's animation and is ready when the loader clears.
|
|
232
|
+
|
|
233
|
+
The exit animation uses `transform: translateY(-100%)` and `opacity` — both compositor
|
|
234
|
+
properties. The loader exits smoothly even if the main thread is busy parsing content
|
|
235
|
+
beneath it.
|
|
236
|
+
|
|
237
|
+
Remove the loader element from the DOM (`loader.remove()`) after the exit transition
|
|
238
|
+
completes. A `position: fixed; z-index: 1000` element left in the DOM blocks pointer
|
|
239
|
+
events on the content below it even when visually transparent, which produces
|
|
240
|
+
interactions that appear broken without any visible explanation.
|