@mthanh/vespercli 2.2.1 → 2.3.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/README.md +3 -2
- package/assets/CATALOG.md +9 -2
- package/assets/claude/.claude-plugin/marketplace.json +9 -9
- package/assets/claude/.claude-plugin/plugin.json +1 -1
- package/assets/claude/agents/code-planner.md +1 -1
- package/assets/claude/agents/code-reviewer.md +1 -1
- package/assets/claude/agents/code-tester.md +1 -1
- package/assets/claude/agents/copywriter.md +1 -1
- package/assets/claude/agents/feature-wrap-document.md +1 -1
- package/assets/claude/agents/janitor.md +1 -1
- package/assets/claude/agents/modernization.md +1 -1
- package/assets/claude/agents/principal-software-engineer.md +1 -1
- package/assets/claude/agents/refine-issue.md +1 -1
- package/assets/claude/agents/se-system-architecture-reviewer.md +1 -1
- package/assets/claude/agents/se-technical-writer.md +1 -1
- package/assets/claude/agents/skill-creator.md +1 -1
- package/assets/claude/agents/task-researcher.md +1 -1
- package/assets/claude/agents/technical-product-manager.md +1 -1
- package/assets/claude/agents/vision-analyst.md +1 -1
- package/assets/claude/agents/vitest.md +1 -1
- package/assets/claude/agents/workspace-learner.md +2 -2
- package/assets/claude/packs/backend/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/backend/agents/firebase-developer.md +1 -1
- package/assets/claude/packs/backend/agents/nodejs-express-developer.md +1 -1
- package/assets/claude/packs/docs/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/frontend/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/frontend/agents/expert-nextjs-developer.md +1 -1
- package/assets/claude/packs/frontend/agents/expert-react-frontend-engineer.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-component-analyzer.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-scenario-scanner.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-test-generator.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-test-planner.md +1 -1
- package/assets/claude/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
- package/assets/claude/packs/graphify/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/integrations/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/playwright/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/playwright/agents/playwright-component-analyzer.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-scenario-scanner.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-test-generator.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-test-planner.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-tester.md +1 -1
- package/assets/claude/packs/polyglot/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/styles/.claude-plugin/plugin.json +1 -1
- package/assets/copilot/agents/code-planner.agent.md +1 -1
- package/assets/copilot/agents/code-reviewer.agent.md +1 -1
- package/assets/copilot/agents/code-tester.agent.md +1 -1
- package/assets/copilot/agents/copywriter.agent.md +1 -1
- package/assets/copilot/agents/feature-wrap-document.agent.md +1 -1
- package/assets/copilot/agents/janitor.agent.md +1 -1
- package/assets/copilot/agents/modernization.agent.md +1 -1
- package/assets/copilot/agents/principal-software-engineer.agent.md +1 -1
- package/assets/copilot/agents/refine-issue.agent.md +1 -1
- package/assets/copilot/agents/se-system-architecture-reviewer.agent.md +1 -1
- package/assets/copilot/agents/se-technical-writer.agent.md +1 -1
- package/assets/copilot/agents/skill-creator.agent.md +1 -1
- package/assets/copilot/agents/task-researcher.agent.md +1 -1
- package/assets/copilot/agents/technical-product-manager.agent.md +1 -1
- package/assets/copilot/agents/vision-analyst.agent.md +1 -1
- package/assets/copilot/agents/vitest.agent.md +1 -1
- package/assets/copilot/agents/workspace-learner.agent.md +2 -2
- package/assets/copilot/packs/backend/agents/firebase-developer.agent.md +1 -1
- package/assets/copilot/packs/backend/agents/nodejs-express-developer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/expert-nextjs-developer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/expert-react-frontend-engineer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-component-analyzer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-scenario-scanner.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-test-generator.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-test-planner.agent.md +1 -1
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
- package/assets/copilot/packs/playwright/agents/playwright-component-analyzer.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-scenario-scanner.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-test-generator.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-test-planner.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-tester.agent.md +1 -1
- package/assets/manifest.json +2 -0
- package/dist/cli.min.js +102 -102
- package/package.json +1 -1
- package/scripts/__snapshots__/generate-claude-assets.test.js.snap +1 -1
- package/scripts/generate-claude-assets.strict.test.js +1 -1
- package/scripts/generate-claude-assets.test.js +6 -6
- package/scripts/packs-layout.test.js +2 -1
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
# Motion
|
|
2
|
+
|
|
3
|
+
Read this before adding any animation. The static page must already be finished and passing
|
|
4
|
+
`inspect.mjs`; motion is the last pass, never a way to rescue a weak layout.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
1. The budget
|
|
9
|
+
2. What strong pages do with motion
|
|
10
|
+
3. Four questions before any animation
|
|
11
|
+
4. Tokens
|
|
12
|
+
5. Scroll behaviours, ranked
|
|
13
|
+
6. Recipes (copy, then adapt)
|
|
14
|
+
7. Signature moves
|
|
15
|
+
8. Reduced motion and performance
|
|
16
|
+
9. What gives generated motion away
|
|
17
|
+
10. Rules for motion
|
|
18
|
+
|
|
19
|
+
## 1. The budget
|
|
20
|
+
|
|
21
|
+
A page that does not move reads as a mock-up; the inspector counts what moves while the page loads and
|
|
22
|
+
scrolls, and fails it under eight elements. The motion layer has five parts:
|
|
23
|
+
|
|
24
|
+
- **Two or three scroll scenes:** the big, scroll-driven moments award sites are remembered for (a
|
|
25
|
+
camera flying through a field of pictures, a picture that grows from a window to the whole screen,
|
|
26
|
+
type that crosses a held stage, cards that deal out and stack). The direction sheet deals them from
|
|
27
|
+
`library/scenes.json` when the library has scenes for the brief; `references/scenes.md` says how to
|
|
28
|
+
fill and fit them. They are where the page is bold; everything else stays quiet so they land.
|
|
29
|
+
|
|
30
|
+
- **One signature moment:** the thing a visitor would describe an hour later: the headline that rises
|
|
31
|
+
out of a mask, the image that opens like a shutter, the wordmark that scales with the scroll. The
|
|
32
|
+
direction sheet from `draw.mjs` names one. Build that one well; it is the only move allowed to be
|
|
33
|
+
loud.
|
|
34
|
+
- **Section entrances, choreographed per section:** the parts of each section arrive in reading order
|
|
35
|
+
(heading, text, picture, details), staggered 60–90ms, rising 16–32px with opacity, once. Vary the
|
|
36
|
+
manner with the content instead of stamping one preset everywhere: a list arrives row by row, a
|
|
37
|
+
picture opens from a mask or settles from a slight scale, a figure counts up once, a statement rises
|
|
38
|
+
line by line.
|
|
39
|
+
- **Feedback under the hand:** buttons that give on press, links that answer the pointer, cards and
|
|
40
|
+
pictures that respond on hover, a menu that opens from where it was clicked.
|
|
41
|
+
- **One moving picture:** a found clip as a muted loop with a poster frame, a slow drift or scale on
|
|
42
|
+
a large photograph, or a scroll-linked move on the hero picture.
|
|
43
|
+
|
|
44
|
+
What is still banned: the same fade-and-slide-up with the same timing on every block, which is the most
|
|
45
|
+
cited motion tell of generated pages; motion that hides content until a script runs, with no fallback;
|
|
46
|
+
bounce and elastic easing; anything that animates layout properties. Uniform motion says nobody decided
|
|
47
|
+
anything; choreographed motion with one protected moment says somebody did.
|
|
48
|
+
|
|
49
|
+
## 2. What strong pages do with motion
|
|
50
|
+
|
|
51
|
+
Measured on the latest 99 Awwwards Sites of the Day (July to October 2026), at 1440 and 390, with
|
|
52
|
+
the transitions, tweens and scroll triggers recorded while each page loaded and was scrolled. Where
|
|
53
|
+
the gallery does something the house rules ban, the house version follows.
|
|
54
|
+
|
|
55
|
+
- **The stack.** Two in three use a smooth-scroll library and three in five ship Three.js; GSAP is
|
|
56
|
+
named on 56. The quiet layer below (reveals, line rises, the ink fill) needs none of it: CSS scroll
|
|
57
|
+
timelines and view transitions do that work, though only 3 and 6 of the sites use them. The big
|
|
58
|
+
scroll scenes are built on GSAP ScrollTrigger, as on the award sites, through the shared stage in
|
|
59
|
+
`assets/scenes/` (`references/scenes.md`).
|
|
60
|
+
- **Curves.** The curves that really ran are strong ease-outs: expo out (`.16,1,.3,1` and
|
|
61
|
+
`.19,1,.22,1`) ran on 14 sites, cubic out on 13, quint out on 7, and by number of transitions the
|
|
62
|
+
quint and expo outs lead every chosen curve. In-out curves are kept for wipes and things that
|
|
63
|
+
travel (cubic and quart in-out, about 15 sites each). Ease-in appears only for exits, short, before
|
|
64
|
+
a slow arrival. Colour changes use plain `ease` (46 sites). Overshoot sits in 19 stylesheets and
|
|
65
|
+
ran on 2.
|
|
66
|
+
- **Durations.** Hover colour 200-300 ms; small interface moves 300-450 ms; reveals and line rises
|
|
67
|
+
600-1,000 ms; picture openings and wipes 1,100-1,600 ms. The median of everything that ran on a
|
|
68
|
+
site is 420 ms.
|
|
69
|
+
- **Staggers.** 20-40 ms between characters, 75-120 ms between lines, 50-100 ms between list items.
|
|
70
|
+
- **Scroll-linked motion is locked to the scrollbar.** Of the sites that scrub, 14 of 18 lock it with
|
|
71
|
+
no smoothing; the other four smooth it by 0.1-3 s. Reveals fire when the element's top is 80-95% of the way
|
|
72
|
+
down the viewport, once.
|
|
73
|
+
- **How much moves.** A median of 130 elements are moved by script on a page, but they come from a
|
|
74
|
+
few gestures: one split heading is 20-60 elements. The inspector's floor (8, warning under 16) is a
|
|
75
|
+
floor, not a target.
|
|
76
|
+
- **Waiting is the most common cost.** 60 of 99 open on a loading screen, 26 of them with a counter.
|
|
77
|
+
Half the usable pages were still unreadable 2.5 s after the browser said the page had loaded, a
|
|
78
|
+
third at 4.5 s. The preloaders were not covering slow pages (their documents loaded faster than the
|
|
79
|
+
rest) and the readers' design marks were the same whatever the wait; only usability fell. House
|
|
80
|
+
version: no preloader, the message readable on the first frame, the load sequence over by 1.2 s.
|
|
81
|
+
- **Pins cost length.** Two in three scrolling pages hold something sticky or pinned. Those pages
|
|
82
|
+
are twice as long (a median 16.6 screens against 7.5) for fewer words per screen (27 against 46).
|
|
83
|
+
House version: at most two held stages, about one screen per step, three screens per stage.
|
|
84
|
+
- **Reduced motion is mostly missing.** 35 of 99 have any `prefers-reduced-motion` rule, 16 have
|
|
85
|
+
three or more; 5 of 26 scroll-scrubbed scenes have one. Every recipe here keeps the finished state
|
|
86
|
+
as the reduced state.
|
|
87
|
+
|
|
88
|
+
## 3. Four questions before any animation
|
|
89
|
+
|
|
90
|
+
1. **How often will someone see it?** Once per visit (hero load, a section reveal): it can take its
|
|
91
|
+
time. Many times per visit (hover, menu, accordion): fast and small. Hundreds of times (keyboard
|
|
92
|
+
actions, typing): no animation.
|
|
93
|
+
2. **What is it for?** Valid answers: it shows where something came from, it confirms an action, it
|
|
94
|
+
keeps a change from being jarring, it explains how the product works, it is the signature moment.
|
|
95
|
+
"It looks nice" is only valid for the signature moment.
|
|
96
|
+
3. **Which easing?** Entering or leaving: ease-out, so movement starts the instant the eye arrives.
|
|
97
|
+
Moving between two places on screen: ease-in-out. Colour and opacity on hover: `ease`. Constant
|
|
98
|
+
travel (marquee, progress): `linear`. `ease-in` makes interfaces feel slow because the first frames
|
|
99
|
+
barely move; do not use it. The CSS keyword curves are weak, so use the tokens below.
|
|
100
|
+
4. **How long?** Press feedback 100–160ms. Hover 150–200ms. Menus and popovers 150–250ms. Dialogs and
|
|
101
|
+
drawers 250–450ms. Section and hero reveals 500–900ms. Leaving is quicker than arriving (about 70%).
|
|
102
|
+
|
|
103
|
+
## 4. Tokens
|
|
104
|
+
|
|
105
|
+
```css
|
|
106
|
+
:root {
|
|
107
|
+
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
|
|
108
|
+
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* new: reveals of 800ms and longer, line rises, openings */
|
|
109
|
+
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
|
|
110
|
+
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
|
|
111
|
+
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* new: something leaving for good, short */
|
|
112
|
+
--t-press: 140ms;
|
|
113
|
+
--t-hover: 180ms;
|
|
114
|
+
--t-ui: 240ms;
|
|
115
|
+
--t-reveal: 800ms;
|
|
116
|
+
--t-open: 1200ms; /* new: picture openings and clip wipes */
|
|
117
|
+
--stagger-line: 90ms; /* new */
|
|
118
|
+
--stagger-item: 70ms; /* new */
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
How the study bears on each:
|
|
123
|
+
|
|
124
|
+
- `--ease-out` (quint out) is in 28 stylesheets with its near twin and is the single most frequent
|
|
125
|
+
chosen curve among transitions that ran. Keep it.
|
|
126
|
+
- `--ease-expo` is new. Expo out (`.16,1,.3,1` and `.19,1,.22,1`) is in 28 stylesheets and ran on 14
|
|
127
|
+
sites, more than any other chosen family, and it is the curve the long reveals use: line rises,
|
|
128
|
+
picture openings, card entrances of 0.9-1.6 s. Use it for anything at `--t-reveal` or longer; it
|
|
129
|
+
replaces `--ease-out` in the masked headline and the reveal-on-scroll recipes.
|
|
130
|
+
- `--ease-in-out` (quart in-out) matches its family (14 stylesheets, 7 ran). Keep it for wipes,
|
|
131
|
+
shutters, menus and page transitions.
|
|
132
|
+
- `--ease-soft` (cubic out) matches the cubic-out family (21 stylesheets, 13 ran), but the study uses it
|
|
133
|
+
for interface moves of 0.3-0.8 s, where `--ease-out` already serves. Narrow it to slow scale and
|
|
134
|
+
drift on photographs.
|
|
135
|
+
- `--ease-exit` is new. Ease-in is right for exactly one job: something that leaves for good (an
|
|
136
|
+
outgoing caption, a replaced slide, a closing panel), short, at about 60-70% of the matching
|
|
137
|
+
entrance. Never for anything the visitor is waiting to see.
|
|
138
|
+
- Not adopted: `.4,0,.2,1` (38 stylesheets) is a framework default, and overshoot curves (19) stay
|
|
139
|
+
banned on interface elements.
|
|
140
|
+
- `--t-hover` stays at 180ms. The study's colour hovers run 200-300ms; a little quicker reads as
|
|
141
|
+
more exact. A hover that moves something (a label roll, an arrow) takes `--t-ui` to 400ms.
|
|
142
|
+
|
|
143
|
+
## 5. Scroll behaviours, ranked
|
|
144
|
+
|
|
145
|
+
| Behaviour | Pages of 99 | What it serves | House version |
|
|
146
|
+
| --- | --- | --- | --- |
|
|
147
|
+
| Smooth-scroll library | 67 | A feeling of weight; makes scrubbed scenes easier to build | Optional, through `Stage.smooth()`: Lenis at lerp 0.1-0.14, off for touch and reduced motion |
|
|
148
|
+
| Held (sticky or pinned) stage | 44 | One picture or object carries several claims; chapters | Yes: the scroll scenes (`references/scenes.md`), two or three per page, one to four screens each; other sticky stages three screens at most |
|
|
149
|
+
| Split text | 40 | Lines and words arriving as they are read; hover rolls | Lines for headings and statements; words only for the ink fill; characters only for a heading of one to three words |
|
|
150
|
+
| Colour by chapter | 34 | Tells chapters apart without padding bands | Yes, as hard cuts at section edges; the header ink follows in 300ms |
|
|
151
|
+
| Scroll-scrubbed scene (camera, object, image sequence) | 26 | Explains a product by moving it | Yes, from the scene library, built on the shared stage with a finished composition for reduced motion |
|
|
152
|
+
| Parallax depth | 24 | Depth in collages; pictures drifting inside oversized frames | Travel under 8% of the element, pictures held at 106-110% in their frames |
|
|
153
|
+
| Marquee | 21 | Life on a static band | Existing "Edge-to-edge marquee": one per page, pausable |
|
|
154
|
+
| Masked line or word rise | 18 | Headings that arrive like set type | Yes: "Masked line reveal" on load, "Lines that rise on scroll" below the fold |
|
|
155
|
+
| Wheel drives a scene, no page scroll | 16 | Browsing one object | Not used (see 2.6) |
|
|
156
|
+
| Blur-in | 15 | Soft arrival | At most 8px of blur, 600ms, gone once settled; never on body text |
|
|
157
|
+
| Picture from inset frame to full-bleed | 14 | The hand-over from a type screen to the big picture | Yes: "Opening window", about one screen of scroll |
|
|
158
|
+
| Grey-to-ink text fill | 14 | Paces one statement | Yes, once per page, starting at 45% ink: "Ink fill" |
|
|
159
|
+
| Sheet slides over a held layer | 11 | Page-turn rhythm between chapters | Yes: new move "Sheet over a held picture" |
|
|
160
|
+
| Scramble or decode | 11 | A "code" voice | Not used (see 2.6) |
|
|
161
|
+
| Header ink by chapter | 10 | The fixed header stays legible | Yes, with colour by chapter |
|
|
162
|
+
| Horizontal track pinned to vertical scroll | 8 | Walking through rooms or a collection | Native drag row first; pinned only when the row is under three screens long, with keyboard support: "Sideways gallery" |
|
|
163
|
+
| Curtain footer | 8 | The page ends as a scene | Existing move |
|
|
164
|
+
| Rolling digits | 6 | Figures that arrive | Yes, for pages whose case is a handful of numbers: "Rolling figures" |
|
|
165
|
+
| Index with hover preview | 5 | Navigating work by name | Existing move, upgraded recipe |
|
|
166
|
+
|
|
167
|
+
Text, split by unit: characters on about 20 pages, lines on about 14, words on about 11. Character
|
|
168
|
+
splits serve short headings and button rolls; on long statements they leave fragments mid-scroll.
|
|
169
|
+
|
|
170
|
+
## 6. Recipes
|
|
171
|
+
|
|
172
|
+
All recipes keep the content visible when JavaScript is off, put every hidden state inside
|
|
173
|
+
`prefers-reduced-motion: no-preference`, and use the tokens above. Two notes that apply to several:
|
|
174
|
+
|
|
175
|
+
- **Scroll timelines need a scroller to watch.** `animation-timeline: view()` takes the nearest scroll
|
|
176
|
+
container, and `overflow: hidden` makes one. A picture inside an `overflow: hidden` frame therefore
|
|
177
|
+
never animates. Use `overflow: clip` on frames, or name the timeline on the section
|
|
178
|
+
(`view-timeline-name: --hero`) and point the children at that name. This also applies to the
|
|
179
|
+
existing "Scroll-linked progress" recipe.
|
|
180
|
+
- **Scroll-linked means linear.** The scroll is the easing. 14 of the 18 scrubbing sites in the study
|
|
181
|
+
lock motion to the scrollbar, which is what CSS scroll timelines do.
|
|
182
|
+
|
|
183
|
+
All recipes keep content visible when JavaScript is off or slow: the hidden state only applies under
|
|
184
|
+
`html.js`, which a one-line inline script sets.
|
|
185
|
+
|
|
186
|
+
```html
|
|
187
|
+
<script>document.documentElement.classList.add('js')</script>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Load sequence in 1.2 seconds
|
|
191
|
+
|
|
192
|
+
Everything is in the HTML and painted on the first frame; the sequence only adds arrival. Nothing
|
|
193
|
+
waits for the load event, and nothing in the navigation animates.
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<header class="hero">
|
|
197
|
+
<h1 class="display lines">
|
|
198
|
+
<span class="line"><span data-line style="--i:0">Stools for</span></span>
|
|
199
|
+
<span class="line"><span data-line style="--i:1">small kitchens</span></span>
|
|
200
|
+
</h1>
|
|
201
|
+
<p class="lead" data-load style="--d:420ms">…</p>
|
|
202
|
+
<a class="button" data-load style="--d:480ms" href="#stools">See the stools</a>
|
|
203
|
+
<figure class="hero__picture" data-open><img src="stool.jpg" alt="…" fetchpriority="high"></figure>
|
|
204
|
+
</header>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```css
|
|
208
|
+
.hero__picture { overflow: clip; margin: 0; }
|
|
209
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
210
|
+
.js .hero [data-line] { transform: translateY(105%); animation: load-line 800ms var(--ease-expo) forwards; animation-delay: calc(100ms + var(--i) * var(--stagger-line)); }
|
|
211
|
+
.js .hero [data-load] { opacity: 0; transform: translateY(12px); animation: load-rise 500ms var(--ease-out) forwards; animation-delay: var(--d); }
|
|
212
|
+
.js .hero [data-open] { clip-path: inset(0 0 100% 0); animation: load-open 1000ms var(--ease-in-out) 150ms forwards; }
|
|
213
|
+
.js .hero [data-open] img { transform: scale(1.08); animation: load-settle 1100ms var(--ease-expo) 100ms forwards; }
|
|
214
|
+
}
|
|
215
|
+
@keyframes load-line { to { transform: none; } }
|
|
216
|
+
@keyframes load-rise { to { opacity: 1; transform: none; } }
|
|
217
|
+
@keyframes load-open { to { clip-path: inset(0); } }
|
|
218
|
+
@keyframes load-settle { to { transform: none; } }
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The timeline: the headline's lines rise from 100 to 990 ms, the picture opens from 150 to 1,150 ms,
|
|
222
|
+
the sentence and the button arrive from 420 to 980 ms. All settled by 1.2 s, four things at most.
|
|
223
|
+
Preload the display font (`<link rel="preload" as="font" type="font/woff2" crossorigin>`) so the lines
|
|
224
|
+
rise in the real face, and give the picture `fetchpriority="high"` and a ground colour sampled from it,
|
|
225
|
+
so a slow image opens onto its own colour rather than a blank box. Study: 22 of 91 pages were readable
|
|
226
|
+
at the first intro frame and 11 more by 1.2 s; the intros the readers called respectful finished in
|
|
227
|
+
about 1-1.2 s; a third of the pages were still unreadable at 4.5 s.
|
|
228
|
+
|
|
229
|
+
### Masked line reveal (the classic gallery headline)
|
|
230
|
+
|
|
231
|
+
Each line slides up from behind its own baseline. Because headlines are capped at two lines, you set
|
|
232
|
+
the line breaks yourself and wrap each line.
|
|
233
|
+
|
|
234
|
+
```html
|
|
235
|
+
<h1 class="lines">
|
|
236
|
+
<span class="line"><span data-line style="--i:0">Stools for</span></span>
|
|
237
|
+
<span class="line"><span data-line style="--i:1">small kitchens</span></span>
|
|
238
|
+
</h1>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```css
|
|
242
|
+
.lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
|
|
243
|
+
.lines [data-line] { display: block; }
|
|
244
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
245
|
+
.js .lines [data-line] {
|
|
246
|
+
transform: translateY(105%);
|
|
247
|
+
animation: line-up 900ms var(--ease-out) forwards;
|
|
248
|
+
animation-delay: calc(var(--i) * 110ms + 150ms);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
@keyframes line-up { to { transform: none; } }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The `padding-bottom` / negative margin pair stops descenders (g, y, p) being clipped by the mask.
|
|
255
|
+
On a narrow phone the two authored lines may each wrap; check the mobile screenshot, and if they do,
|
|
256
|
+
author a shorter mobile break with a `<br class="only-wide">` pattern or reduce the size.
|
|
257
|
+
|
|
258
|
+
### Reveal on scroll (once)
|
|
259
|
+
|
|
260
|
+
For the two or three sections that deserve it, not for every block.
|
|
261
|
+
|
|
262
|
+
```css
|
|
263
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
264
|
+
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
|
|
265
|
+
.js [data-reveal].is-in { opacity: 1; transform: none; }
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
const io = new IntersectionObserver((entries) => {
|
|
271
|
+
for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
|
|
272
|
+
}, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 });
|
|
273
|
+
document.querySelectorAll('[data-reveal]').forEach((el) => io.observe(el));
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Never attach reveals with a scroll listener, never replay them when the element re-enters, and never
|
|
277
|
+
leave anything at `opacity: 0` if the observer fails to fire (the `html.js` gate handles no-JS; the
|
|
278
|
+
`unobserve` handles replays).
|
|
279
|
+
|
|
280
|
+
### Image shutter
|
|
281
|
+
|
|
282
|
+
An image opens from one edge while scaling down slightly inside its frame. Gallery staple; use it for
|
|
283
|
+
the one hero or showcase image.
|
|
284
|
+
|
|
285
|
+
```css
|
|
286
|
+
.shutter { overflow: hidden; }
|
|
287
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
288
|
+
.js .shutter { clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
|
|
289
|
+
.js .shutter img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
|
|
290
|
+
.js .shutter.is-in { clip-path: inset(0); }
|
|
291
|
+
.js .shutter.is-in img { transform: none; }
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Press and hover
|
|
296
|
+
|
|
297
|
+
```css
|
|
298
|
+
.button { transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
|
|
299
|
+
.button:active { transform: scale(0.97); }
|
|
300
|
+
.link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
|
|
301
|
+
@media (hover: hover) and (pointer: fine) {
|
|
302
|
+
.button:hover { background-color: var(--accent-strong); }
|
|
303
|
+
.link:hover { text-decoration-color: currentColor; }
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Hover styles go behind `(hover: hover)` because touch devices fire hover on tap and leave it stuck.
|
|
308
|
+
Hover should raise contrast, never lower it. Do not change font weight on hover (the text reflows).
|
|
309
|
+
Do not lift or scale every card on hover; one consistent, small response is enough.
|
|
310
|
+
|
|
311
|
+
### Scroll-linked progress without JavaScript
|
|
312
|
+
|
|
313
|
+
For a wordmark that scales as the hero leaves, an image that drifts a few percent, or a progress bar.
|
|
314
|
+
Wrap in `@supports` so browsers without scroll timelines get the static state.
|
|
315
|
+
|
|
316
|
+
```css
|
|
317
|
+
@supports (animation-timeline: view()) {
|
|
318
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
319
|
+
.drift { animation: drift linear both; animation-timeline: view(); animation-range: entry 0% exit 100%; }
|
|
320
|
+
@keyframes drift { from { transform: translateY(-4%); } to { transform: translateY(4%); } }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Keep the travel small (under 8% of the element). Large parallax makes text hard to read and looks
|
|
326
|
+
dated.
|
|
327
|
+
|
|
328
|
+
### Pinned story
|
|
329
|
+
|
|
330
|
+
A section whose heading stays put while items pass. Use `position: sticky`; do not hijack scrolling.
|
|
331
|
+
|
|
332
|
+
```css
|
|
333
|
+
.story { display: grid; grid-template-columns: 5fr 7fr; gap: var(--gutter); align-items: start; }
|
|
334
|
+
.story__title { position: sticky; top: 18vh; }
|
|
335
|
+
@media (max-width: 800px) { .story { grid-template-columns: 1fr; } .story__title { position: static; } }
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Index with preview, anchored to the row
|
|
339
|
+
|
|
340
|
+
Rows of names; pointing at a row or tabbing to it opens one frame level with that row. The frame
|
|
341
|
+
follows the row, never the pointer.
|
|
342
|
+
|
|
343
|
+
```html
|
|
344
|
+
<section class="index">
|
|
345
|
+
<ul class="index__list">
|
|
346
|
+
<li><a class="index__row" href="/work/harbour-house" data-preview="/img/harbour-house.jpg">
|
|
347
|
+
<span class="index__name">Harbour House</span><span class="index__year">2025</span>
|
|
348
|
+
<img class="index__thumb" src="/img/harbour-house-small.jpg" alt="" loading="lazy" width="640" height="400">
|
|
349
|
+
</a></li>
|
|
350
|
+
<!-- … -->
|
|
351
|
+
</ul>
|
|
352
|
+
<div class="index__frame" aria-hidden="true"><img alt=""></div>
|
|
353
|
+
</section>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
```css
|
|
357
|
+
.index { position: relative; }
|
|
358
|
+
.index__list { list-style: none; margin: 0; padding: 0; }
|
|
359
|
+
.index__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0 var(--gutter); padding-block: 0.3em; font-size: var(--step-head); color: inherit; text-decoration: none; transition: opacity var(--t-hover) ease; }
|
|
360
|
+
.index__thumb { order: -1; flex-basis: 100%; width: 100%; height: auto; margin-bottom: 0.4rem; }
|
|
361
|
+
.index__frame { display: none; }
|
|
362
|
+
@media (hover: hover) and (pointer: fine) {
|
|
363
|
+
.index__list { max-width: 62%; }
|
|
364
|
+
.index__thumb { display: none; }
|
|
365
|
+
.index__list:has(.index__row:hover, .index__row:focus-visible) .index__row:not(:hover, :focus-visible) { opacity: 0.4; }
|
|
366
|
+
.index__frame { display: block; position: absolute; top: 0; right: var(--margin); width: 30%; aspect-ratio: 4 / 3; z-index: 1; pointer-events: none; clip-path: inset(100% 0 0 0); transition: clip-path 320ms var(--ease-out), transform 360ms var(--ease-out); }
|
|
367
|
+
.index__frame.is-open { clip-path: inset(0); }
|
|
368
|
+
.index__frame img { width: 100%; height: 100%; object-fit: cover; }
|
|
369
|
+
}
|
|
370
|
+
@media (prefers-reduced-motion: reduce) { .index__frame { transition: none; } }
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```js
|
|
374
|
+
(() => {
|
|
375
|
+
const root = document.querySelector('.index');
|
|
376
|
+
if (!root) return;
|
|
377
|
+
const frame = root.querySelector('.index__frame');
|
|
378
|
+
const picture = frame.querySelector('img');
|
|
379
|
+
const fine = matchMedia('(hover: hover) and (pointer: fine)');
|
|
380
|
+
let warmed = false;
|
|
381
|
+
const show = (row) => {
|
|
382
|
+
if (!fine.matches) return;
|
|
383
|
+
if (!warmed) { warmed = true; root.querySelectorAll('[data-preview]').forEach((a) => { new Image().src = a.dataset.preview; }); }
|
|
384
|
+
picture.src = row.dataset.preview;
|
|
385
|
+
const y = row.offsetTop + row.offsetHeight / 2 - frame.offsetHeight / 2;
|
|
386
|
+
const max = root.offsetHeight - frame.offsetHeight;
|
|
387
|
+
const opening = !frame.classList.contains('is-open');
|
|
388
|
+
if (opening) frame.style.transitionProperty = 'clip-path'; // open in place, do not slide in from the top
|
|
389
|
+
frame.style.transform = `translateY(${Math.min(max, Math.max(0, y))}px)`;
|
|
390
|
+
if (opening) { frame.getBoundingClientRect(); frame.style.transitionProperty = ''; }
|
|
391
|
+
frame.classList.add('is-open');
|
|
392
|
+
};
|
|
393
|
+
const hide = () => frame.classList.remove('is-open');
|
|
394
|
+
root.querySelectorAll('.index__row').forEach((row) => {
|
|
395
|
+
row.addEventListener('pointerenter', () => show(row));
|
|
396
|
+
row.addEventListener('focus', () => show(row));
|
|
397
|
+
});
|
|
398
|
+
root.addEventListener('pointerleave', hide);
|
|
399
|
+
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) hide(); });
|
|
400
|
+
})();
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
On touch screens each row shows its own picture above the name. Study: 5 pages navigate work this way;
|
|
404
|
+
frames from about 470x270 to large plates behind the list; the other rows dimmed to about 0.3 (0.4 here, the floor for dimmed text). The
|
|
405
|
+
image trails and cursor-borne previews seen elsewhere in the study are the gimmick version.
|
|
406
|
+
|
|
407
|
+
### Lines that rise on scroll
|
|
408
|
+
|
|
409
|
+
For a section heading or a statement whose line breaks you do not control. Splits after the fonts
|
|
410
|
+
load, reveals once, then gives the plain text back so it rewraps normally. Plain text only (no links or
|
|
411
|
+
emphasis inside).
|
|
412
|
+
|
|
413
|
+
```html
|
|
414
|
+
<h2 class="head" data-lines>Turned by hand in one small workshop</h2>
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
```css
|
|
418
|
+
[data-lines] .line { display: block; overflow: clip; padding-bottom: 0.12em; margin-bottom: -0.12em; }
|
|
419
|
+
[data-lines] .line > span { display: block; }
|
|
420
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
421
|
+
.is-split .line > span { transform: translateY(110%); }
|
|
422
|
+
.is-split.is-in .line > span { transform: none; transition: transform 900ms var(--ease-expo) calc(var(--i) * var(--stagger-line)); }
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
```js
|
|
427
|
+
(() => {
|
|
428
|
+
const els = [...document.querySelectorAll('[data-lines]')];
|
|
429
|
+
if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
430
|
+
const restore = (el) => { el.textContent = el.dataset.text; el.classList.remove('is-split', 'is-in'); };
|
|
431
|
+
const split = (el) => {
|
|
432
|
+
const text = el.textContent.trim().replace(/\s+/g, ' ');
|
|
433
|
+
el.dataset.text = text;
|
|
434
|
+
el.textContent = '';
|
|
435
|
+
const words = text.split(' ').map((word) => {
|
|
436
|
+
const s = document.createElement('span');
|
|
437
|
+
s.style.display = 'inline-block';
|
|
438
|
+
s.textContent = word;
|
|
439
|
+
el.append(s, ' ');
|
|
440
|
+
return s;
|
|
441
|
+
});
|
|
442
|
+
const lines = []; // group the words by the line they landed on
|
|
443
|
+
for (const w of words) {
|
|
444
|
+
const last = lines.at(-1);
|
|
445
|
+
if (last && Math.abs(last.top - w.offsetTop) < 4) last.words.push(w.textContent);
|
|
446
|
+
else lines.push({ top: w.offsetTop, words: [w.textContent] });
|
|
447
|
+
}
|
|
448
|
+
el.textContent = '';
|
|
449
|
+
lines.forEach((line, i) => {
|
|
450
|
+
const mask = document.createElement('span');
|
|
451
|
+
const inner = document.createElement('span');
|
|
452
|
+
mask.className = 'line';
|
|
453
|
+
inner.style.setProperty('--i', i);
|
|
454
|
+
inner.textContent = line.words.join(' ');
|
|
455
|
+
mask.append(inner);
|
|
456
|
+
el.append(mask, ' ');
|
|
457
|
+
});
|
|
458
|
+
el.classList.add('is-split');
|
|
459
|
+
};
|
|
460
|
+
const io = new IntersectionObserver((entries) => {
|
|
461
|
+
for (const e of entries) {
|
|
462
|
+
if (!e.isIntersecting) continue;
|
|
463
|
+
io.unobserve(e.target);
|
|
464
|
+
e.target.classList.add('is-in');
|
|
465
|
+
e.target.querySelector('.line:last-of-type > span')
|
|
466
|
+
.addEventListener('transitionend', () => restore(e.target), { once: true });
|
|
467
|
+
}
|
|
468
|
+
}, { rootMargin: '0px 0px -12% 0px' });
|
|
469
|
+
document.fonts.ready.then(() => els.forEach((el) => { split(el); io.observe(el); }));
|
|
470
|
+
let width = innerWidth; // a width change before the reveal: give the text back
|
|
471
|
+
addEventListener('resize', () => {
|
|
472
|
+
if (innerWidth === width) return;
|
|
473
|
+
width = innerWidth;
|
|
474
|
+
els.filter((el) => el.matches('.is-split:not(.is-in)')).forEach((el) => { io.unobserve(el); restore(el); });
|
|
475
|
+
});
|
|
476
|
+
})();
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Study: masked rises from 100-120% below the line, 0.6-1.2 s, 75-120 ms between lines, triggered when
|
|
480
|
+
the block's top is 80-95% down the viewport. Reduced motion: the script does not run; the text is plain.
|
|
481
|
+
|
|
482
|
+
### Opening window
|
|
483
|
+
|
|
484
|
+
The hand-over from a type screen to the one big picture: the picture first shows as a window in the
|
|
485
|
+
middle of a held stage and opens to full-bleed over about one screen of scroll.
|
|
486
|
+
|
|
487
|
+
```html
|
|
488
|
+
<section class="opening">
|
|
489
|
+
<div class="opening__stage">
|
|
490
|
+
<img src="workshop-wide.jpg" alt="The workshop at first light, benches cleared" width="2400" height="1500">
|
|
491
|
+
</div>
|
|
492
|
+
</section>
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
```css
|
|
496
|
+
.opening__stage { height: 100svh; overflow: clip; }
|
|
497
|
+
.opening__stage img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
498
|
+
@supports (animation-timeline: view()) {
|
|
499
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
500
|
+
.opening { height: 200svh; view-timeline-name: --opening; --inset-y: 20%; --inset-x: 28%; }
|
|
501
|
+
.opening__stage { position: sticky; top: 0; animation: opening-window linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
|
|
502
|
+
.opening__stage img { animation: opening-settle linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
@media (max-width: 700px) { .opening { --inset-y: 18%; --inset-x: 8%; } }
|
|
506
|
+
@keyframes opening-window { from { clip-path: inset(var(--inset-y) var(--inset-x)); } to { clip-path: inset(0); } }
|
|
507
|
+
@keyframes opening-settle { from { transform: scale(1.15); } to { transform: none; } }
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
The section is two screens tall; the stage is held for one, and the window is fully open after three
|
|
511
|
+
quarters of it, so the full-bleed picture rests for a moment before the page moves on. Study: 14 pages;
|
|
512
|
+
start windows 38-60% of the width; runways 600-1,800 px, and the readers asked for one to two screens.
|
|
513
|
+
Without scroll timelines, and under reduced motion, the picture is a full-bleed screen.
|
|
514
|
+
|
|
515
|
+
### Held picture, passing captions
|
|
516
|
+
|
|
517
|
+
The pinned story built for photographs: the picture holds on one side while short captions pass on
|
|
518
|
+
the other; the caption at the middle of the screen is lit and its picture is shown.
|
|
519
|
+
|
|
520
|
+
```html
|
|
521
|
+
<section class="steps">
|
|
522
|
+
<figure class="steps__media">
|
|
523
|
+
<div class="steps__pictures">
|
|
524
|
+
<img src="seasoning.jpg" alt="Ash boards stacked to season" class="is-on">
|
|
525
|
+
<img src="turning.jpg" alt="A leg being turned on the lathe">
|
|
526
|
+
<img src="finish.jpg" alt="Oil rubbed into a finished seat">
|
|
527
|
+
</div>
|
|
528
|
+
<figcaption class="steps__count" aria-hidden="true"></figcaption>
|
|
529
|
+
</figure>
|
|
530
|
+
<ol class="steps__list">
|
|
531
|
+
<li class="steps__item"><h3>Seasoned for a year</h3><p>…</p></li>
|
|
532
|
+
<li class="steps__item"><h3>Turned by hand</h3><p>…</p></li>
|
|
533
|
+
<li class="steps__item"><h3>Finished with oil</h3><p>…</p></li>
|
|
534
|
+
</ol>
|
|
535
|
+
</section>
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
```css
|
|
539
|
+
.steps { display: grid; grid-template-columns: 7fr 5fr; column-gap: var(--gutter); }
|
|
540
|
+
.steps__media { margin: 0; align-self: start; } /* a stretched grid item cannot stick */
|
|
541
|
+
.steps__pictures img { display: block; width: 100%; height: auto; }
|
|
542
|
+
.steps__list { list-style: none; margin: 0; padding: 0; }
|
|
543
|
+
.steps__item { min-height: 80svh; display: flex; flex-direction: column; justify-content: center; }
|
|
544
|
+
.js .steps__media { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 0.75rem; }
|
|
545
|
+
.js .steps__pictures { position: relative; aspect-ratio: 4 / 5; max-height: 80svh; }
|
|
546
|
+
.js .steps__pictures img { position: absolute; inset: 0; height: 100%; object-fit: cover; opacity: 0; transition: opacity 500ms ease; }
|
|
547
|
+
.js .steps__pictures img.is-on { opacity: 1; }
|
|
548
|
+
.js .steps__item { opacity: 0.4; transition: opacity var(--t-ui) ease; }
|
|
549
|
+
.js .steps__item.is-active { opacity: 1; }
|
|
550
|
+
@media (max-width: 800px) {
|
|
551
|
+
.steps { display: block; } /* the section, not a grid area, contains the sticky picture */
|
|
552
|
+
.js .steps__media { height: auto; z-index: 1; background: var(--bg); padding-block: 0.5rem; }
|
|
553
|
+
.js .steps__pictures { aspect-ratio: 3 / 2; max-height: 42svh; }
|
|
554
|
+
.steps__item { min-height: 60svh; }
|
|
555
|
+
}
|
|
556
|
+
@media (prefers-reduced-motion: reduce) { .js .steps__pictures img, .js .steps__item { transition: none; } }
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
(() => {
|
|
561
|
+
const root = document.querySelector('.steps');
|
|
562
|
+
if (!root || !('IntersectionObserver' in window)) return;
|
|
563
|
+
const items = [...root.querySelectorAll('.steps__item')];
|
|
564
|
+
const pictures = [...root.querySelectorAll('.steps__pictures img')];
|
|
565
|
+
const count = root.querySelector('.steps__count');
|
|
566
|
+
const show = (i) => {
|
|
567
|
+
items.forEach((el, k) => el.classList.toggle('is-active', k === i));
|
|
568
|
+
pictures.forEach((el, k) => el.classList.toggle('is-on', k === i));
|
|
569
|
+
if (count) count.textContent = `${i + 1} of ${items.length}`;
|
|
570
|
+
};
|
|
571
|
+
const io = new IntersectionObserver((entries) => {
|
|
572
|
+
for (const e of entries) if (e.isIntersecting) show(items.indexOf(e.target));
|
|
573
|
+
}, { rootMargin: '-45% 0px -45% 0px' }); // a band across the middle of the screen
|
|
574
|
+
items.forEach((el) => io.observe(el));
|
|
575
|
+
show(0);
|
|
576
|
+
})();
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Without JavaScript the pictures and captions are two plain columns. Study: inactive items at 20-40%
|
|
580
|
+
(dims of 10-15% read as missing text, so the floor here is 40%), crossfades of 400-600 ms, about one
|
|
581
|
+
screen per step; the readers asked for a visible count on any held sequence of more than two steps.
|
|
582
|
+
The count is a plain sentence in the body face, not a zero-padded numeral.
|
|
583
|
+
|
|
584
|
+
### Sideways gallery
|
|
585
|
+
|
|
586
|
+
A row of pictures that the vertical scroll moves sideways while the stage is held, for a short
|
|
587
|
+
collection (eight items or fewer). The native drag row is the base; the pinned version is added only
|
|
588
|
+
on wide screens with motion allowed, and only when the row is under three screens long.
|
|
589
|
+
|
|
590
|
+
```html
|
|
591
|
+
<section class="gallery">
|
|
592
|
+
<div class="gallery__stage">
|
|
593
|
+
<h2 class="head">Eight kitchens, one stool</h2>
|
|
594
|
+
<ul class="gallery__track">
|
|
595
|
+
<li class="gallery__item"><a href="/kitchens/leith"><img src="leith.jpg" alt="A narrow galley kitchen in Leith" width="1200" height="1500"><span>Leith</span></a></li>
|
|
596
|
+
<!-- … -->
|
|
597
|
+
</ul>
|
|
598
|
+
</div>
|
|
599
|
+
</section>
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
```css
|
|
603
|
+
.gallery__track { display: flex; gap: var(--gutter); list-style: none; margin: 0; padding: 0 var(--margin); overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--margin); }
|
|
604
|
+
.gallery__item { flex: 0 0 min(72vw, 28rem); scroll-snap-align: start; }
|
|
605
|
+
.gallery__item img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
|
|
606
|
+
.gallery.is-pinned .gallery__stage { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 2rem; overflow: clip; }
|
|
607
|
+
.gallery.is-pinned .gallery__track { overflow: visible; scroll-snap-type: none; }
|
|
608
|
+
.gallery.is-pinned .gallery__item { flex-basis: calc(62svh * 0.8); }
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
```js
|
|
612
|
+
(() => {
|
|
613
|
+
const root = document.querySelector('.gallery');
|
|
614
|
+
if (!root) return;
|
|
615
|
+
const track = root.querySelector('.gallery__track');
|
|
616
|
+
const wide = matchMedia('(min-width: 900px) and (prefers-reduced-motion: no-preference)');
|
|
617
|
+
let dx = 0, queued = false;
|
|
618
|
+
const place = () => {
|
|
619
|
+
queued = false;
|
|
620
|
+
if (!root.classList.contains('is-pinned')) return;
|
|
621
|
+
const p = Math.min(1, Math.max(0, -root.getBoundingClientRect().top / dx));
|
|
622
|
+
track.style.transform = `translate3d(${(-p * dx).toFixed(1)}px, 0, 0)`;
|
|
623
|
+
};
|
|
624
|
+
const measure = () => {
|
|
625
|
+
root.classList.add('is-pinned'); // measure the row as it would be when pinned
|
|
626
|
+
track.style.transform = '';
|
|
627
|
+
dx = track.scrollWidth - track.clientWidth;
|
|
628
|
+
const pin = wide.matches && dx > 0 && dx < innerHeight * 3;
|
|
629
|
+
root.classList.toggle('is-pinned', pin);
|
|
630
|
+
root.style.height = pin ? `${innerHeight + dx}px` : ''; // one pixel of scroll moves the row one pixel
|
|
631
|
+
place();
|
|
632
|
+
};
|
|
633
|
+
track.addEventListener('focusin', (e) => { // keyboard: bring the focused item into view
|
|
634
|
+
if (!root.classList.contains('is-pinned')) return;
|
|
635
|
+
const item = e.target.closest('.gallery__item');
|
|
636
|
+
const x = Math.min(dx, Math.max(0, item.offsetLeft - parseFloat(getComputedStyle(track).paddingLeft)));
|
|
637
|
+
scrollTo({ top: root.getBoundingClientRect().top + scrollY + x, behavior: 'instant' });
|
|
638
|
+
});
|
|
639
|
+
addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(place); } }, { passive: true });
|
|
640
|
+
addEventListener('resize', measure);
|
|
641
|
+
addEventListener('load', measure);
|
|
642
|
+
wide.addEventListener('change', measure);
|
|
643
|
+
measure();
|
|
644
|
+
})();
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
Study: 8 pages pinned a horizontal track; their pages ran a median 25 screens, and the readers' worst
|
|
648
|
+
cases spent 9 screens on three cards and 10,000-23,000 px of wheel on one track. Here the speed is 1:1
|
|
649
|
+
and the hold ends when the last picture reaches the margin. Phones, narrow windows, reduced motion and
|
|
650
|
+
long rows all get the native row with snapping, which also works with touch, trackpads and the
|
|
651
|
+
keyboard. Never put the only copy of a call to action inside the moving row.
|
|
652
|
+
|
|
653
|
+
### Receding hero
|
|
654
|
+
|
|
655
|
+
As the first screen leaves, its picture sinks a little and darkens while the words lift away a little
|
|
656
|
+
faster, so the hero recedes instead of simply scrolling off.
|
|
657
|
+
|
|
658
|
+
```html
|
|
659
|
+
<header class="hero">
|
|
660
|
+
<img class="hero__picture" src="kitchen.jpg" alt="…" width="2400" height="1600" fetchpriority="high">
|
|
661
|
+
<div class="hero__copy"><h1 class="display">…</h1><p class="lead">…</p></div>
|
|
662
|
+
</header>
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
```css
|
|
666
|
+
.hero { position: relative; min-height: 100svh; overflow: clip; display: grid; align-items: end; view-timeline-name: --hero; }
|
|
667
|
+
.hero__picture { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
|
668
|
+
.hero::after { content: ''; position: absolute; inset: 0; background: var(--ink); opacity: 0; pointer-events: none; }
|
|
669
|
+
.hero__copy { position: relative; z-index: 1; padding: var(--margin); }
|
|
670
|
+
@supports (animation-timeline: view()) {
|
|
671
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
672
|
+
.hero__picture { animation: hero-sink linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
|
|
673
|
+
.hero::after { animation: hero-dim linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
|
|
674
|
+
.hero__copy { animation: hero-lift linear both; animation-timeline: --hero; animation-range: exit 0% exit 70%; }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
@keyframes hero-sink { to { transform: translateY(8%) scale(1.06); } }
|
|
678
|
+
@keyframes hero-dim { to { opacity: 0.35; } }
|
|
679
|
+
@keyframes hero-lift { to { transform: translateY(-6%); opacity: 0; } }
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
The `exit` range runs from the moment the hero's top meets the top of the screen until its bottom
|
|
683
|
+
does, which for a first screen is the first screen of scroll. Study: hero pictures that sink and dim as
|
|
684
|
+
they leave, a heading scrubbed from 140 to 60 px, a title card that scales down on the phone; the
|
|
685
|
+
readers called it quiet and the page "one sheet". Travel stays under 8%; the overlay is a flat ink at
|
|
686
|
+
up to 35%, not a gradient. Static where unsupported or reduced.
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
### Page to page
|
|
693
|
+
|
|
694
|
+
Navigation between pages of the site with the browser's own view transitions: the old page fades
|
|
695
|
+
quickly, the new one rises in, and the picture the visitor clicked travels into place as the new
|
|
696
|
+
page's hero. No library, no full-screen curtain.
|
|
697
|
+
|
|
698
|
+
```css
|
|
699
|
+
/* on every page */
|
|
700
|
+
@view-transition { navigation: auto; }
|
|
701
|
+
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
|
|
702
|
+
::view-transition-old(root) { animation: vt-leave 200ms var(--ease-exit) both; }
|
|
703
|
+
::view-transition-new(root) { animation: vt-arrive 380ms var(--ease-out) 60ms both; }
|
|
704
|
+
::view-transition-group(*) { animation-duration: 480ms; animation-timing-function: var(--ease-in-out); }
|
|
705
|
+
::view-transition-image-pair(*.picture) { overflow: clip; }
|
|
706
|
+
::view-transition-old(*.picture), ::view-transition-new(*.picture) { height: 100%; object-fit: cover; }
|
|
707
|
+
@keyframes vt-leave { to { opacity: 0; } }
|
|
708
|
+
@keyframes vt-arrive { from { opacity: 0; transform: translateY(16px); } }
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
```html
|
|
712
|
+
<!-- on the index: the card picture of one project -->
|
|
713
|
+
<img src="/img/leith.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
|
|
714
|
+
<!-- on that project's page: its hero picture -->
|
|
715
|
+
<img src="/img/leith-wide.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Each name may appear once per page. Browsers without cross-document view transitions simply navigate.
|
|
719
|
+
Study: 16 pages load a page-transition library and run full-screen curtains of 0.7-2.4 s; 6 use view
|
|
720
|
+
transitions. The new page should be readable within half a second.
|
|
721
|
+
|
|
722
|
+
### Ink fill
|
|
723
|
+
|
|
724
|
+
One statement that fills from part-ink to full ink as it rises through the screen, line by line. It is
|
|
725
|
+
readable before it fills; the fill only sets the pace.
|
|
726
|
+
|
|
727
|
+
```html
|
|
728
|
+
<p class="statement head" data-fill>Nine people, one workshop, no catalogue: every stool is made for the kitchen it goes to.</p>
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
```css
|
|
732
|
+
[data-fill] { --fill: var(--ink); color: var(--fill); }
|
|
733
|
+
@supports (animation-timeline: view()) {
|
|
734
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
735
|
+
.is-filling > span { display: inline-block; color: color-mix(in oklab, var(--fill) 45%, transparent); animation: ink-fill linear both; animation-timeline: view(); animation-range: cover 20% cover 45%; }
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
@keyframes ink-fill { to { color: var(--fill); } }
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
```js
|
|
742
|
+
(() => {
|
|
743
|
+
if (!CSS.supports('animation-timeline: view()') || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
744
|
+
document.querySelectorAll('[data-fill]').forEach((el) => {
|
|
745
|
+
const words = el.textContent.trim().split(/\s+/);
|
|
746
|
+
el.textContent = '';
|
|
747
|
+
words.forEach((word, i) => {
|
|
748
|
+
const s = document.createElement('span');
|
|
749
|
+
s.textContent = word;
|
|
750
|
+
el.append(s, i < words.length - 1 ? ' ' : '');
|
|
751
|
+
});
|
|
752
|
+
el.classList.add('is-filling');
|
|
753
|
+
});
|
|
754
|
+
})();
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
Each word watches its own position, so the words of a line fill together as the line moves from three
|
|
758
|
+
quarters of the way down the screen to just past half way. Set `--fill` on a dark chapter to its ink.
|
|
759
|
+
Study: 14 pages scrub a statement from grey to ink; unread words started at about 25-30% and settled
|
|
760
|
+
frames still caught statements half pale, which reads as disabled text. Here: one statement per page,
|
|
761
|
+
three to five lines, 32px or larger, starting at 45% (about 3:1 against the ground, enough for large
|
|
762
|
+
text). Full ink without scroll timelines or under reduced motion.
|
|
763
|
+
|
|
764
|
+
### Rolling figures
|
|
765
|
+
|
|
766
|
+
For pages whose case is a handful of numbers: each digit rolls up to its value once, the columns a
|
|
767
|
+
beat apart. The real number stays in the text for screen readers and search.
|
|
768
|
+
|
|
769
|
+
```html
|
|
770
|
+
<p class="figure"><span class="display" data-roll>1987</span> the year the first stool was sold</p>
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
```css
|
|
774
|
+
.roll__digits { display: inline-flex; font-variant-numeric: tabular-nums; line-height: 1; }
|
|
775
|
+
.roll__col { display: inline-block; height: 1em; overflow: clip; }
|
|
776
|
+
.roll__strip { display: flex; flex-direction: column; transform: translateY(calc(var(--d) * -1em)); }
|
|
777
|
+
.roll__strip > span { height: 1em; }
|
|
778
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
779
|
+
.roll:not(.is-in) .roll__strip { transform: none; }
|
|
780
|
+
.roll.is-in .roll__strip { transition: transform 1100ms var(--ease-expo) calc(var(--i) * var(--stagger-item)); }
|
|
781
|
+
}
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
```js
|
|
785
|
+
(() => {
|
|
786
|
+
const els = [...document.querySelectorAll('[data-roll]')];
|
|
787
|
+
if (!els.length) return;
|
|
788
|
+
els.forEach((el) => {
|
|
789
|
+
const value = el.textContent.trim();
|
|
790
|
+
const said = Object.assign(document.createElement('span'), { className: 'sr-only', textContent: value });
|
|
791
|
+
const shown = Object.assign(document.createElement('span'), { className: 'roll__digits' });
|
|
792
|
+
shown.setAttribute('aria-hidden', 'true');
|
|
793
|
+
let i = 0;
|
|
794
|
+
for (const ch of value) {
|
|
795
|
+
if (!/\d/.test(ch)) { shown.append(ch); continue; }
|
|
796
|
+
const col = Object.assign(document.createElement('span'), { className: 'roll__col' });
|
|
797
|
+
const strip = Object.assign(document.createElement('span'), { className: 'roll__strip' });
|
|
798
|
+
strip.style.setProperty('--d', ch);
|
|
799
|
+
strip.style.setProperty('--i', i++);
|
|
800
|
+
for (let n = 0; n < 10; n++) strip.append(Object.assign(document.createElement('span'), { textContent: n }));
|
|
801
|
+
col.append(strip);
|
|
802
|
+
shown.append(col);
|
|
803
|
+
}
|
|
804
|
+
el.classList.add('roll');
|
|
805
|
+
el.replaceChildren(said, shown);
|
|
806
|
+
});
|
|
807
|
+
if (!('IntersectionObserver' in window)) { els.forEach((el) => el.classList.add('is-in')); return; }
|
|
808
|
+
const io = new IntersectionObserver((entries) => {
|
|
809
|
+
for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
|
|
810
|
+
}, { rootMargin: '0px 0px -15% 0px' });
|
|
811
|
+
els.forEach((el) => io.observe(el));
|
|
812
|
+
})();
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
Study: 6 pages roll digit columns (stats, a year, coordinates), about 0.9-1.1 s per column on an
|
|
816
|
+
expo-out, staggered by column. Use it for three or four figures at display size, never in running text
|
|
817
|
+
and never for a value that changes after load. Reduced motion: the figure is set at its value.
|
|
818
|
+
|
|
819
|
+
### Accordion
|
|
820
|
+
|
|
821
|
+
```css
|
|
822
|
+
.acc__panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 320ms var(--ease-out); }
|
|
823
|
+
.acc__panel > div { overflow: hidden; }
|
|
824
|
+
.acc[open] .acc__panel, .acc__item.is-open .acc__panel { grid-template-rows: 1fr; }
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
Animating `grid-template-rows` from `0fr` to `1fr` opens to the content's real height without measuring
|
|
828
|
+
it in JavaScript.
|
|
829
|
+
|
|
830
|
+
### Menu or dialog
|
|
831
|
+
|
|
832
|
+
```css
|
|
833
|
+
.menu { transform-origin: top right; transition: opacity var(--t-ui) var(--ease-out), transform var(--t-ui) var(--ease-out); }
|
|
834
|
+
.menu[hidden], .menu.is-closed { opacity: 0; transform: scale(0.97) translateY(-6px); pointer-events: none; }
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
Things open from where they were triggered (`transform-origin` at the trigger). Nothing enters from
|
|
838
|
+
`scale(0)`; start at 0.95–0.98. A centred dialog is the one case that scales from its centre.
|
|
839
|
+
|
|
840
|
+
## 7. Signature moves
|
|
841
|
+
|
|
842
|
+
The direction sheet names one. Each of these was seen repeatedly on the studied gallery sites.
|
|
843
|
+
|
|
844
|
+
| Move | What happens | Build with |
|
|
845
|
+
| --- | --- | --- |
|
|
846
|
+
| Masked headline | Lines rise from behind their baselines on load | Masked line reveal recipe |
|
|
847
|
+
| Shutter image | Hero or showcase image opens from an edge, settling from 1.12 scale | Image shutter recipe |
|
|
848
|
+
| Scaling wordmark | A giant wordmark shrinks or grows a few percent as the hero scrolls out | Scroll-linked recipe on `scale` |
|
|
849
|
+
| Pinned story | A heading holds while its items scroll past | Sticky recipe |
|
|
850
|
+
| Index with preview | Text rows; the hovered row shows its image | Index with preview, anchored to the row |
|
|
851
|
+
| Curtain footer | The last section is revealed from under the page as you reach the end | `position: sticky; bottom: 0` footer under a main with a background and `z-index` |
|
|
852
|
+
| Slow crossfade | Hero image crossfades through 3–4 photographs, 5–6s each | Stacked images, opacity keyframes, pause when the tab is hidden |
|
|
853
|
+
| Edge-to-edge marquee | One line of very large type travels sideways, slowly | Duplicate the content, `translateX(-50%)` linear loop, pause on hover and under reduced motion |
|
|
854
|
+
| Drag row | A horizontally scrollable row of large images with scroll snapping | `overflow-x: auto; scroll-snap-type: x mandatory`, no library |
|
|
855
|
+
| Colour by chapter | Each chapter is its own full-bleed colour field, so the ground changes at section edges; a fixed header takes the ink of the chapter beneath | Section backgrounds in CSS; `base.js` sets `--header-ink` from `data-ink`. Never recolour the whole page from an observer |
|
|
856
|
+
| Opening window | A type screen hands over to the big picture: a window in the middle of a held stage opens to full-bleed over about one screen | Opening window recipe |
|
|
857
|
+
| Ink-fill statement | One statement fills from 45% to full ink as it rises through the screen | Ink fill recipe |
|
|
858
|
+
| Sheet over a held picture | A full-bleed photograph holds for one screen; the next chapter slides up over it as an opaque sheet | A 100svh `position: sticky; top: 0` figure followed by a section with its own background and a higher `z-index`; no script |
|
|
859
|
+
| Sideways gallery | A short row of pictures moves sideways while the vertical scroll holds the stage | Sideways gallery recipe |
|
|
860
|
+
| Pushed line | One line of very large type is pushed sideways by the scroll, reading whole at the middle of its run | `animation-timeline: view()` on the line, `translateX` from 12% to -12%, linear; static where unsupported |
|
|
861
|
+
| Rolling figures | The few figures that make the case roll up to their values once | Rolling figures recipe |
|
|
862
|
+
| Carried picture | The picture clicked in a list travels into place as the next page's hero | Page to page recipe |
|
|
863
|
+
|
|
864
|
+
Pick by subject. A portfolio wants the index with preview; a restaurant wants the slow crossfade; a
|
|
865
|
+
product wants the shutter on its hero object. If the move says nothing about the subject, it is
|
|
866
|
+
decoration and the page is better without it.
|
|
867
|
+
|
|
868
|
+
Pick by subject, as before: the opening window for a product or a place with one great photograph; the
|
|
869
|
+
sheet for chapters that each own a picture; the sideways gallery for a short set of rooms, kitchens or
|
|
870
|
+
pieces; the pushed line for a brand whose claim is a word; rolling figures for a report, a fund or a
|
|
871
|
+
practice that leads with numbers; the carried picture for a portfolio whose index and case pages are
|
|
872
|
+
both built.
|
|
873
|
+
|
|
874
|
+
## 8. Reduced motion and performance
|
|
875
|
+
|
|
876
|
+
- `prefers-reduced-motion: reduce` means fewer and gentler, not none: keep opacity and colour changes,
|
|
877
|
+
drop travel, scale, parallax and autoplay. All recipes above are written inside
|
|
878
|
+
`no-preference` blocks so the reduced state is simply the finished state.
|
|
879
|
+
- Animate `transform`, `opacity` and `clip-path`. Animating `width`, `height`, `top`, `left`, `margin`
|
|
880
|
+
or `padding` forces layout on every frame. `grid-template-rows` for accordions is the accepted
|
|
881
|
+
exception because it is short and rare.
|
|
882
|
+
- Do not drive many children from one changing CSS variable on a parent; set the transform on the
|
|
883
|
+
element that moves.
|
|
884
|
+
- `will-change` only on an element that is about to animate, removed afterwards.
|
|
885
|
+
- Pause loops when off screen or when the tab is hidden.
|
|
886
|
+
- No scroll hijacking and no custom cursor. They cost usability, which is 30% of an Awwwards score and
|
|
887
|
+
the line most winners score lowest on. Smoothing only through `Stage.smooth()`, which stays off for
|
|
888
|
+
touch and reduced motion.
|
|
889
|
+
|
|
890
|
+
## 9. What gives generated motion away
|
|
891
|
+
|
|
892
|
+
- The same fade-up on every block.
|
|
893
|
+
- A dozen staggered entrances in the hero.
|
|
894
|
+
- Hover lift or zoom on every card and image.
|
|
895
|
+
- Bounce or elastic easing on interface elements.
|
|
896
|
+
- Infinite loops that mean nothing: pulsing dots, floating shapes, shimmering text, rotating badges.
|
|
897
|
+
- A reveal that never fires and leaves a blank band in the page. Run `inspect.mjs` after the motion
|
|
898
|
+
pass and look at every screenshot for missing content.
|
|
899
|
+
|
|
900
|
+
### Seen in the latest Sites of the Day
|
|
901
|
+
|
|
902
|
+
Seen in the study, with how often, and what the house does instead.
|
|
903
|
+
|
|
904
|
+
- **Preloaders and counters (60 of 99; 26 with a counter).** Half the usable pages were unreadable
|
|
905
|
+
2.5 s after load, a third at 4.5 s; the preloaders sat mostly on pages that had already loaded, and
|
|
906
|
+
25 of the 29 that loaded in under 3 s still showed nothing readable at 4.5 s. Counters stalled,
|
|
907
|
+
outlasted the load or were tweens with nothing behind them. House: no preloader. If a scene must
|
|
908
|
+
load, the HTML first screen (headline, picture, action) is there at once and the scene fades in
|
|
909
|
+
behind it when ready.
|
|
910
|
+
- **Gates (10 of 99): enter, sound, age or gesture before any content.** House: none. Sound is a
|
|
911
|
+
control on the page, off by default.
|
|
912
|
+
- **Scroll hijacking (16 wheel-driven scenes, 9 of them endless, plus 14 single screens where the wheel
|
|
913
|
+
does nothing).** The visitor cannot tell where they are, how much is left or how to get out, and the
|
|
914
|
+
keyboard and screen readers get nothing. Usability 6.01 against 6.50 for pages that scroll. House:
|
|
915
|
+
native scroll; any scene is driven by scroll position on a sticky stage that ends, with a count.
|
|
916
|
+
- **Smooth-scroll libraries (67 of 99).** They make the scrubbed scenes feel weighted, and they also
|
|
917
|
+
put a delay between the hand and the page; with a long ease the page keeps sliding after the wheel
|
|
918
|
+
stops.
|
|
919
|
+
- **Custom cursors (28 of 99).** Followers, image trails, a lens that smears the headline it sits on,
|
|
920
|
+
labels that only exist on hover; 10 of the 28 have any reduced-motion rule. House: the system cursor;
|
|
921
|
+
labels are on the page.
|
|
922
|
+
- **Scramble and typewriter text (11 and 5).** The headline unreadable for four seconds, body copy
|
|
923
|
+
garbled mid-scroll, actions held back until the typing ends. House: not used. Real words arrive whole.
|
|
924
|
+
- **Long held runways.** Pages with pins average twice the length for fewer words per screen; 17 pages
|
|
925
|
+
are 20 screens or longer, some with whole screens that hold only a slow rotation. House: about one
|
|
926
|
+
screen per step, three per stage, every held screen carries a line of text.
|
|
927
|
+
- **Content held at zero opacity.** Reveals that keep text invisible until the scroll settles, inactive
|
|
928
|
+
items dimmed to 10-15%, statements left half pale. House: reveals only below the fold, once, never
|
|
929
|
+
longer than 900 ms in view; dims no lower than 40%; fills start at 45%.
|
|
930
|
+
- **Motion over words.** Marquees and drifting objects that cross statements, a sliding line that crops
|
|
931
|
+
its own words, blur left on text mid-move. House: nothing moves across text the visitor is reading;
|
|
932
|
+
a moving line must read whole at some point of its run.
|
|
933
|
+
- **Overshoot and bounce (19 stylesheets).** House: still banned on interface elements.
|
|
934
|
+
- **Endless loops with no off switch.** Grain, pulsing dots, equalisers, spinning badges, running with no
|
|
935
|
+
reduced-motion rule on most pages. House: loops pause off screen and stop under reduced motion.
|
|
936
|
+
|
|
937
|
+
Where the study's work does something the house bans, the house version: hairline progress bars and
|
|
938
|
+
tick rails on held stages become a plain count in the body face ("2 of 4"); zero-padded counters
|
|
939
|
+
("01/08") become a plain count or nothing; crossfading gradient grounds become flat chapter colours with
|
|
940
|
+
hard cuts; monospace HUD labels become small text in the text face.
|
|
941
|
+
|
|
942
|
+
## 10. Rules for motion
|
|
943
|
+
|
|
944
|
+
1. **First frame.** The headline, the sentence and the picture are in the HTML and painted on the first
|
|
945
|
+
frame. The load sequence starts without waiting for the load event and is settled by 1.2 s, with
|
|
946
|
+
four elements at most.
|
|
947
|
+
2. **No preloader, no counter, no gate, no custom cursor, no wheel capture.** Smoothing only through
|
|
948
|
+
`Stage.smooth()` (Lenis at lerp 0.1-0.14, off for touch and reduced motion): weight, never a page
|
|
949
|
+
that keeps sliding after the hand stops.
|
|
950
|
+
3. **Curves.** `--ease-out` for anything under 600ms; `--ease-expo` for reveals of 800ms or more;
|
|
951
|
+
`--ease-in-out` for wipes and things that travel; `--ease-exit` only for things leaving for good;
|
|
952
|
+
`linear` for scroll-linked motion and loops; plain `ease` for colour. No overshoot.
|
|
953
|
+
4. **Durations.** Press 140ms; hover colour 180-200ms; hover that moves 300-400ms; menus 240ms; reveals
|
|
954
|
+
700-900ms; picture openings 1,100-1,300ms; exits 60-70% of the matching entrance.
|
|
955
|
+
5. **Staggers.** Lines 80-110ms; list items 50-90ms; characters 20-40ms and only in a heading of one to
|
|
956
|
+
three words; four steps at most in any one sequence.
|
|
957
|
+
6. **Reveals.** Fire once, when the element's top is 85-90% of the way down the viewport; rise 12-32px
|
|
958
|
+
or from a line mask; nothing in view stays hidden for more than 900ms; nothing above the fold waits
|
|
959
|
+
for scroll.
|
|
960
|
+
7. **Scroll scenes are where the page is bold.** The big moves come from the scene library
|
|
961
|
+
(`references/scenes.md`): the direction sheet deals two or three per page, each built on
|
|
962
|
+
`assets/scenes/stage.js`, each held for one to four screens, each with a phone version and a
|
|
963
|
+
finished composition. Inside a scene the scene's own numbers apply (a picture may grow from a
|
|
964
|
+
window to the whole screen, type may cross the stage, a camera may fly through a field of
|
|
965
|
+
pictures); rules 8 and 10 still hold for the text a visitor is reading.
|
|
966
|
+
8. **Outside the scenes, scroll-linked travel stays quiet.** Linear and locked to the scrollbar;
|
|
967
|
+
parallax under 8% of the element; scale between 0.94 and 1.15; built with scroll timelines inside
|
|
968
|
+
`@supports`, static without them. Other held stages: sticky, about one screen per step, three
|
|
969
|
+
screens at most, a line of text on every held screen.
|
|
970
|
+
9. **One of each.** One signature move (a dealt scene can be it); one moving picture; one marquee at
|
|
971
|
+
most. A quiet section between two scenes, so each one lands.
|
|
972
|
+
10. **Text stays readable.** Fills start at 45% ink; dims no lower than 40%; blur at most 8px and gone
|
|
973
|
+
once settled; nothing moves across text being read.
|
|
974
|
+
11. **Colour by chapter** as hard cuts at section edges; the fixed header's ink follows in 300ms.
|
|
975
|
+
12. **Horizontal motion.** The native drag row first; the pinned sideways gallery only for rows under
|
|
976
|
+
three screens long, with keyboard support.
|
|
977
|
+
13. **Page to page.** View transitions, 480ms at most, off under reduced motion.
|
|
978
|
+
14. **Reduced motion.** Every hidden state lives inside `prefers-reduced-motion: no-preference`; the
|
|
979
|
+
reduced page is the finished page; loops stop. Two in three award pages skip this, which is a
|
|
980
|
+
reason to do it, not a licence to skip it.
|