@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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. 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.