Files
hallmark/skill/references/microinteractions.md
T
YoussefandClaude Opus 4.7 606a404261 v0.6.0 — Tracejam fixes, three new slop-test gates, two new SaaS tests, gallery thumbnails
## Tracejam (test 05) defects fixed

- Horizontal scroll suppressed via `html { overflow-x: clip }` + `body` fallback
- Navbar items vertically centered (`align-items: center`, `line-height: 1`)
- Wordmark switched from Geist to Fraunces (`--font-wordmark`) — different register from body
- Hero highlight raised from baseline to mid-x-height — reads as highlighter, not fat underline
- Browser-frame chrome dots get `aria-hidden="true"`

## Skill hardening — three new slop-test gates (35 → 38)

- **Gate 36**: page must not horizontally scroll on any viewport 320–1920 px
- **Gate 37**: decorative effects on text (highlighter / accent stroke / mark) must be visually verified at x-height, not baseline
- **Gate 38**: interactive bars must declare `align-items: center` + `line-height: 1`

## New reference rules

- `layout-and-space.md` § Page-edge clipping — global clip is mandatory under any clipped-edge enrichment
- `typography.md` § Wordmark / logo — wordmark may use a different display face; should on Bento / Stat-Led / Workbench / Marquee Hero
- `microinteractions.md` § When to ship motion by default — default-on for SaaS-shaped archetypes, default-off for Editorial / Manifesto / Letter / Quote-Led / Long Document; six recipes documented (number reveal, pricing lift, marquee, stagger, recommended-tier pulse, CTA hover)
- `macrostructures.md` § SaaS page sequence — hero → social proof → features → testimonials → pricing → FAQ → CTA → footer; with voice rules (real prices, specific testimonials, conversational FAQ)

## Meridian Manifesto (test 04) extended

7 sections → 11 sections. Added § What we refuse (anti-list with strikethrough), § Working rules (7 numbered operational principles), § The practice (3-paragraph narrative with drop-cap), § What we read (annotated bibliography with 8 titles). Voice consistent with the original manifesto — declarative, no testimonials, no pricing.

## Two new SaaS-focused tests

- **Test 07 — Foundry** (Stat-Led + Newsprint). SOC2 / ISO27001 compliance automation. Hero is one giant number ("847" — animated counter from 0). 9 sections incl. 3-tier pricing, 3 testimonials with role + company, 8-question FAQ. Pricing card hover-lift + recommended-tier pulse-once.
- **Test 08 — Cohort** (Marquee Hero + Salon). Cohort-based courses platform. Continuous-scroll marquee of course titles. Alternating-side feature rows with hand-built CSS visualisations (card-stack, hours grid, thread, report). Stagger-reveal on testimonials. 2-tier pricing, 6-question FAQ, centered Salon footer.

Both tests respect prefers-reduced-motion. Both pass the 38-gate slop test. Both demonstrate the new SaaS page sequence.

## Gallery thumbnails

`site/_tests/_thumbs/01–08.png` — eight 1024×640 screenshots captured via Chrome headless. Gallery cards on the landing page now show real visual previews above the text. Cards inherit theme tokens (radius, rule, shadow) so switching the landing page's theme reskins the card frames around the screenshots. Hover scales the thumb 1.025× over 500 ms; respects prefers-reduced-motion.

Section title updated: "Six pages, six different shapes." → "Eight pages, eight different shapes."

## SKILL.md version bump

0.5.0 → 0.6.0.

## Files

- 13 modified, 6 created (tests 07 & 08 directories), 8 thumbnails added.
- ~840 lines added, ~25 removed.
- All skill files mirrored into `~/.claude/skills/hallmark/`.

## Deferred to next session

- **Study + redesign combined verb** — single-shot screenshot-to-rebuilt-page.
- **Auto-generated OG cards** — Node + Puppeteer utility for 1200×630 PNG from macrostructure stamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:14:27 +01:00

19 KiB
Raw Blame History

Microinteractions

The single biggest gap in 2026's anti-slop canon. Most skills correct typography and colour; very few correct the small, repeated moments where an interface either feels designed or feels generated. This file is the correction.

A microinteraction is one event with four parts: trigger → rules → feedback → loops/modes (Saffer). Get any of those wrong and the interface feels uncrafted. Ship them all right and the interface feels made — even when nothing else is unusual.

Principles

  • Motion has intent or motion is cut. Every animation must clarify, guide, or confirm. If you cannot name what a transition communicates, it is decoration. Decoration is slop.
  • Silent success. A successful action does not deserve a "Done!" toast. If the user sees the result, they don't need a confirmation. Reserve toasts for failures and for actions that hide their own effect.
  • Optimism with rollback. Update the UI immediately on user action. Send the request in the background. If it fails, animate the rollback and offer Undo. Round-trip latency is a perception killer.
  • Restraint, not restraint-as-personality. Hallmark is not "no motion." Hallmark is the right motion in the right place. A drag handle that springs into focus on grab is good. A page where every card pulses on hover is slop.
  • Reduced motion is a first-class state, not an afterthought. Every interaction defines its reduced-motion behaviour explicitly. Default is: collapse spatial motion to opacity crossfade, keep duration ≤ 150ms, preserve functional state changes.
  • Keyboard first, hover second. Every hover affordance has a focus equivalent. No interaction is hover-only.

When to ship motion by default

The skill biases toward motion-cut. But certain archetypes feel broken without motion — they're so visually busy (or so number-led) that complete stillness reads as a screenshot rather than an interface. For these archetypes, ship 23 small purposeful microinteractions automatically, without waiting for the user to ask.

Default-on archetypes: Bento Grid · Stat-Led · Workbench · Marquee Hero · Conversational FAQ

Default-off archetypes: Editorial · Manifesto · Letter · Quote-Led · Type Specimen · Long Document · Index-First · Letter

For default-on, pick two or three from this menu (never more than three primitives per page):

Microinteraction When to ship Recipe
Number reveal Stat-Led hero, headline numbers anywhere IntersectionObserver fires on viewport entry; requestAnimationFrame counts from 0 to target over 1.21.6 s with --ease-out. Reduced-motion: skip animation, render final value.
Pricing card lift Pricing tier cards translateY(-3px) + shadow upgrade on :hover, 180 ms --ease-out. Active state: drop back to translateY(0) over 60 ms (the press).
CTA hover lift Primary CTA buttons translateY(-1.5px) + background-fade. 200 ms --ease-out. Active state at 60 ms.
Marquee scroll Marquee Hero, customer-logo strip @keyframes marquee translateX(-100%) over 4060 s, infinite. Pauses on hover. Reduced-motion: stops the scroll, shows the first three items.
Stagger reveal Testimonials, feature cards, gallery IntersectionObserver fires on each card; 100 ms stagger; opacity 0 → 1 + translateY(8px → 0); --ease-out 400 ms. One-shot only — never re-fires on scroll.
Recommended-tier pulse The middle pricing tier One-shot @keyframes pulse-border 2 s, runs once on viewport entry. Subtle: opacity 0.4 → 1 → 0.4 on the border. Don't loop.
Caret blink Terminal hero, code mockup @keyframes blink 1 s steps(2) infinite on a 1ch-wide block. Reduced-motion: solid block, no blink.
Number tick on data update Dashboard live values See Number tick recipe below.

Hard rules for default-on motion

  1. Every animation respects prefers-reduced-motion: reduce — either skip entirely or run at 0.01 s.
  2. No more than three distinct animation primitives per page. A counter + a hover-lift + a marquee = three. Don't add a fourth. The temptation to layer "just one more" is the slop pull.
  3. No scroll-linked animations on viewports below 40 rem (existing mobile rule, component-cookbook.md § Mobile collapse).
  4. No animation longer than 2 s except continuous loops (marquee, ambient breathing, caret blink).
  5. The "if I removed this animation, would anyone notice?" test still applies — but for the default-on set, the answer is "yes, the page would feel screenshot-stiff and the brand would feel thin."

What never gets default motion

  • Body text reveals on scroll. Reading is not a cinematic experience.
  • Background gradient shifts. Distracting.
  • Cursor followers. Always slop.
  • Section-by-section fade-up-stagger. Pick one orchestrated entrance, not twelve.
  • Tab content sliding sideways. Crossfade only (see Tab change recipe below).

When the page is default-off (Editorial, Manifesto, etc.), motion is opt-in — the user must ask. Stillness is the brand on those pages.

The timing canon

Pick from these durations. Do not invent new ones.

Bucket Use for
80120 ms Instant feedback (button press tick, checkbox state, keystroke echo). The brain reads anything in this window as immediate.
150200 ms Hover state transitions, focus rings appearing, single-property fades, tooltip appears (with delay before — see below).
250300 ms Modal / dropdown / sheet opens, content fades in, validation icon scales in, tab content crossfade.
400500 ms Toast slides in, page-load section reveal, complex multi-property transitions, accordion open.
0 ms The right answer surprisingly often. Focus state, keyboard navigation, error appearance — many things should not animate at all.

Exit durations are 6075% of the corresponding entrance. A 300ms enter pairs with a 200ms exit. Never the reverse.

The easing canon

Three curves cover ~90% of UI motion. Tokenise them and never freestyle.

:root {
  /* Entering elements — decelerate into place */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  /* Exiting elements — accelerate away */
  --ease-in: cubic-bezier(0.7, 0, 0.84, 0);

  /* State toggles — symmetrical */
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);

  /* Material 3 standard alternative — slightly less aggressive */
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
}

Spring physics replaces eases for physical interactions only — drag-and-drop release, swipe-to-dismiss, picker wheel snap, satisfying button press scale-bounce. Otherwise: ease.

Spring config Feel Use for
stiffness: 50, damping: 20 Gentle, no overshoot Calm reveals; almost an ease
stiffness: 180, damping: 22 Snappy, slight overshoot Drag release; toggle handle
stiffness: 280, damping: 26 Stiff, minimal bounce Picker snap; haptic-like button press
stiffness: 400, damping: 40 Very stiff, no bounce Position corrections; not a spring per se

Banned curves: ease (the browser default — flat and uncrafted), linear except for progress bars and infinite loaders, anything with overshoot above ~110% (cubic-bezier(0.34, 1.56, 0.64, 1) and friends). Bounce is dated and signals templated work.

Recipes

Each recipe: trigger, what changes, duration, easing, accessibility note. If a recipe is missing here, return to the principles and derive it from them.

Button press

Trigger: pointer down. Changes: transform: scale(0.98) on press, base styling on release. Duration: 100ms in, 150ms out. Easing: in --ease-in, out --ease-out. A11y: focus ring stays visible; never animate the focus ring's existence.

.btn {
  transition: background-color var(--dur-short) var(--ease-out),
              transform 100ms var(--ease-out);
}
.btn:hover { background: var(--color-ink); color: var(--color-paper); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }

Input focus + label float

Trigger: focus event. Changes: border-bottom colour, label slides up + shrinks, optional subtle background tint. Duration: 200ms. Easing: --ease-out. Critical: the change happens before the user types — Stripe / Linear use this to confirm the field is alive. A11y: :focus-visible only, not :focus; respect reduced-motion by removing the slide and keeping only the colour change.

Form validation

Trigger: blur after the field has been touched once (the "touched" pattern), then re-validate on every input. Never validate on every keystroke from the start — it's hostile. Changes: icon scales in (200ms --ease-out), border tints, helper text replaces. Three-part error message: what broke, why, how to fix.

Toast notification

Trigger: action completes (or fails). Stack at one viewport corner; new toasts push existing ones in one direction; existing toasts do not reposition when a new one arrives. Duration: 400ms slide-in --ease-out, 46s dwell, 300ms slide-out --ease-in. Pause auto-dismiss on hover/focus. Use sparingly: if the action's effect is visible (a row was deleted; you can see the row is gone), no toast. Errors always get a toast with retry/undo.

Modal open / close

Trigger: explicit user action (click, key shortcut). Backdrop fades 300ms --ease-out. Content scales 0.96 → 1.0 + opacity 0 → 1, 300ms --ease-out. Close: 220ms --ease-in, scale 1.0 → 0.98, opacity → 0. Use the native <dialog> element — it handles focus trap and ::backdrop for free. inert on background. First focus to first interactive element, not the close button. Reduced motion: opacity-only crossfade, 150ms.

Dropdown / menu

Trigger: click or key shortcut. Open: 180ms --ease-out, optional 30ms-stagger items if there are ≤ 8 of them. Close: 140ms --ease-in. Light-dismiss on outside click and Escape. Use the Popover API where available. Anchor positioning: flip when within 16px of viewport edge.

Tooltip

Trigger: mouse hover (with 8001000ms delay to prevent flash on casual movement) OR keyboard focus (with 0ms delay — keyboard users reached this deliberately, never delay them). Animation: 150ms --ease-out opacity. WCAG 1.4.13: tooltip must be hoverable (you can move pointer onto it without it disappearing), persistent (doesn't disappear on accidental movement), and dismissible (Escape).

Tab change

Trigger: click or arrow-key. Underline slides transform: translateX() + width transition, 250ms --ease-out. Outgoing content fades 100ms --ease-in, incoming fades 150ms --ease-out with a 50ms delay. Never animate the tab content's height — animate grid-template-rows: 0fr → 1fr if the tabs change height.

Number tick

Trigger: data loaded. Counter increments from 0 to value over 400ms with --ease-out applied to the value, not the element. Use Intl.NumberFormat for locale-correct separators. A11y: announce the final value with aria-live="polite", not every tick. Reduced motion: skip the tick, show the final value.

Copy-to-clipboard

Trigger: click. Changes: button label swaps to "Copied" with a check icon; revert after 2.5s. No toast. The label change is the feedback. Restore on mouseleave if user moves away sooner.

btn.addEventListener("click", async () => {
  await navigator.clipboard.writeText(value);
  btn.dataset.state = "copied";
  setTimeout(() => delete btn.dataset.state, 2500);
});
.copy-btn[data-state="copied"] .copy-btn__label::after { content: "  ✓  Copied"; }
.copy-btn[data-state="copied"] .copy-btn__label > * { opacity: 0; }

Drag handle

Trigger: hover (after 12s delay — Notion's pattern). Changes: handle reveals via opacity transition, cursor switches to grab. On grab: cursor grabbing, ghost element at 50% opacity follows pointer, drop indicator (1px line, accent colour) tracks the nearest valid drop target. Spring stiffness 280 / damping 26 on release-snap. A11y: arrow-key reorder when the row is focused; announce drag state with aria-live.

Optimistic update with rollback

Trigger: any action with a known-correct local prediction (toggle, like, archive, reorder). Changes: state mutates immediately; the row visibly updates. Async request fires. On success: nothing happens — silent success is the marker of taste. On failure: 200ms colour rollback animation + a toast with one Undo button. The toast does not auto-dismiss while the user might still want it.

const prevState = item.completed;
item.completed = !prevState; render();
try {
  await api.update(item);
} catch {
  item.completed = prevState; render();
  toast({ tone: "error", message: "Couldn't save.", action: { label: "Try again", run: retry } });
}

Search-as-you-type

Trigger: input event. Debounce by 250ms before requesting; while debouncing, show a subtle indicator (border opacity or label colour shift). Highlight matches in results with <mark>. A11y: announce result count with aria-live="polite" after debounce settles, never per keystroke.

Command palette navigation

Trigger: ⌘K or /. Open: instant, no animation. Arrow-keys move selection — the indicator transitions between rows (120ms --ease-out on the highlight's transform: translateY()), but the items themselves don't move. Enter selects. Escape closes. Items stagger-fade in on first open only, never on filter change. The text input stays focused throughout. This is the Linear / Raycast / Vercel pattern.

Page-load reveals

One orchestrated entrance. Stagger by DOM index, capped at ~500ms total. Use IntersectionObserver, never scroll listeners. After first reveal, no more on-scroll animations — let the page just be there. Theme-specific themes (Atelier, Salon, Newsprint) skip reveals entirely; that is correct, not a bug.

The named tells (what AI defaults produce)

These are the microinteraction signatures of generated code. The slop test in SKILL.md checks for them; treat any one as a critical finding.

  1. transition-all. Every property animating, including ones that should be instant (visibility, display, focus rings). Always specify the properties. The class is banned in Hallmark output.
  2. Universal hover:scale-105. Every card lifts on hover, with no shadow change, no easing specified, no purpose. AI's reflexive "make it interactive" gesture.
  3. Bouncy overshoot easings. cubic-bezier(0.34, 1.56, 0.64, 1) and friends on UI elements. Tasteless 2018-throwback. Reserve overshoots for genuine physical interactions (drag release).
  4. Multiple simultaneous hover effects. A card that translates, scales, shadows, colour-shifts, and rotates on hover. Pick one signal.
  5. Animated gradient backgrounds on hover. The gradient slides through colour space. Distracting, expensive, communicates nothing.
  6. Glow halos on text. Heavy text-shadow for "neon" — destroys contrast, hurts legibility.
  7. Cursor-follower dots. A trailing dot that lags behind the pointer. Adds nothing; triggers vestibular issues.
  8. Custom cursors on every interactive element. Conflicts with OS conventions; users learn nothing about what's clickable.
  9. Auto-rotating carousels with no controls. WCAG 2.2.2 violation. Always.
  10. Parallax on scroll. Different layers moving at different speeds. Vestibular trigger; rarely serves the content.
  11. transition applied to layout properties. Animating width, height, padding, margin, top, left. Triggers reflow on every frame. Use transform or grid-template-rows: 0fr → 1fr.
  12. Universal scroll-triggered fade-up-stagger. Every section fades in on intersection. Page never settles. Pick one orchestrated entrance.
  13. Celebratory success toasts. "Done!" when the user just saved a thing they can see was saved. Silent success is taste.
  14. Confirmation dialogs for reversible actions. "Are you sure you want to delete this?" Replace with optimistic delete + Undo toast.
  15. Spinners with no minimum visible time. Spinner flashes on/off when the action completes in 80ms. Either delay showing it (150ms) or set a minimum visible duration (300ms).
  16. Tooltips with the same delay on hover and focus. Hover should delay 8001000ms; focus should appear immediately. They are different intents.
  17. Focus rings that animate in. The ring fades in over 200ms, leaving keyboard users without an indicator at the start of the transition. Focus rings appear instantly. Always.
  18. Color-only state change. A field turns red on error with no icon, no text, no border style change. Fails WCAG 1.4.1 and is unreadable for ~8% of men.
  19. Toasts that move existing content. New toast pushes the page down; dismissed toast lets it spring back. Stack toasts; don't shift layout.
  20. Hover delays on touch. A :hover state that the touch user can never reach because there's no equivalent focus / tap behaviour.

Theme-aware microinteractions

Microinteractions adapt to the theme. The same button press is louder in Brutal than in Atelier. Apply a multiplier per theme:

Theme Duration scale Easing flavour Notes
Specimen 1.0× --ease-out Default. Restrained.
Midnight 0.9× --ease-out Snappy, technical.
Brutal 0.75× --ease-out (sharper) Fast, decisive. No spring.
Garden 1.2× --ease-out Calm. Springs welcome.
Atelier 1.3× --ease-out (very gentle) Generous; almost no movement.
Newsprint 0× none Static. Print metaphor.
Terminal 0× none, except cursor blink Print + monospace metaphor.
Manifesto 0.7× --ease-out (sharp) Snap into place.
Salon 1.4× --ease-out (very gentle) Softest.
Linen 1.2× --ease-out Calm.
Almanac 0.85× --ease-out Functional, like a reference book.
Sport 0.7× --ease-out (sharp) Quick, italic-energy.

If the theme has duration scale 0×, you do not animate. The page does not move. That is a deliberate design choice; it is not broken.

Accessibility ground truth

Every recipe in this file must pass these checks before shipping.

  • prefers-reduced-motion: reduce is honoured. Spatial motion collapses to opacity crossfade ≤ 150ms. Functional state changes (progress bars, spinners) slow but remain.
  • Focus rings are 23 px, ≥ 3:1 contrast, never animated in/out, present on every interactive element via :focus-visible.
  • Hit targets ≥ 44 × 44 CSS px on touch surfaces.
  • No reliance on colour alone for state. Pair with icon, text, or pattern.
  • No flashing above 3 Hz. Even subtle pulse animations need rate caps.
  • Keyboard equivalents for every hover affordance. No exceptions.
  • aria-live on async state updates, but polite not assertive unless safety-critical.

When in doubt, cut

Most pages have too much motion, not too little. Before shipping, walk through every animation in your output and ask: what would happen if this animation were instant? If the answer is "nothing — the user wouldn't notice", remove the animation. If the answer is "the user would lose information about what changed", keep it.

Reaching for a static answer is a sign of taste. Reaching for more motion is the AI default.