Files
K-D-C 6457347dd6 docs(patterns): add 'Patterns We Like' scrapbook + link from README
A running list of UI effects/interactions worth keeping, each with the four-point
bar (does a job, respects reduced-motion, transform/opacity only, degrades) it
must clear before shipping. README structure + quick-start now point to it.
2026-06-06 11:37:57 +00:00

91 lines
5.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Patterns We Like
A running scrapbook of UI effects, interactions and micro-patterns we've come across and want to keep — so a future app or project can reach for something proven instead of reinventing it.
This list sits slightly apart from the canonical [principles](../principles/README.md) on purpose. Those say *"clarity over cleverness"* and *"motion is feedback, never decorates."* The entries here are often **delight** effects that flirt with that line. That's the point of the doc: keep the tempting stuff in one place **and** record the bar each must clear before it earns a spot in a shipping product. A pattern we like is not a pattern we'll always use.
## The bar (when a delight effect earns its place)
Before any effect below ships in a KDC product, it has to clear all four:
-**It does a job.** It communicates state, affordance, continuity or confirmation — not just "looks nice." If it's purely decorative, it doesn't ship (per [Motion](../../foundations/motion/README.md)).
-**It respects `prefers-reduced-motion: reduce`** — collapses to a static/instant state, no exceptions.
-**It animates `transform` / `opacity` only** — never `width` / `height` / layout. Compositor-friendly or it doesn't go in.
-**It degrades gracefully** — works without it, and doesn't trap focus, block input, or break on keyboard/touch.
If an entry can't meet the bar in a given spot, that's a "no" for that spot — not a reason to drop the pattern from the list.
## How to add an entry
Keep it light — append a block in this shape. Don't turn it into a form.
```
### <name>
**Status:** idea | trialed | adopted-in-<app>
**What it is:** one line.
**Why we like it:** one or two lines.
**Snippet:** the smallest working version.
**Fits / avoid:** where it earns its place, and where it doesn't.
**Watch-outs:** the honest gotchas — performance, a11y, edge cases.
**Found:** where it came from (link / who / when).
```
**Status legend**`idea` (caught our eye, untried) · `trialed` (prototyped in a branch) · `adopted-in-<app>` (shipped, name the app).
---
## Entries
### Proximity-magnification dock
**Status:** idea
**What it is:** macOS-dock-style hover magnification — across a row (or column) of equal items, the one nearest the cursor scales up, neighbours scale less, tapering to nothing past a set radius. The bulge follows the pointer.
**Why we like it:** tiny amount of code, self-resetting (far items naturally land back at scale 1), and `scale` is compositor-cheap. Used in the right place it doubles as a targeting affordance — it literally points at the thing you're about to click.
**Snippet** — the whole trick is "distance → 0..1 closeness → size":
```js
// Vanilla, horizontal. 120 = reach in px; 0.5 = max growth (→ 1.5×).
onpointermove = e =>
document.querySelectorAll(".dock > *").forEach(el => {
const r = el.getBoundingClientRect();
const t = Math.max(0, 1 - Math.abs(e.clientX - (r.x + r.width / 2)) / 120);
el.style.scale = 1 + t * 0.5;
});
```
In our apps (all React 19) it belongs in an effect with cached rects + rAF + cleanup, and gated on reduced-motion:
```jsx
useEffect(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; // clears the bar
const items = [...dockRef.current.children];
let rects = items.map(el => el.getBoundingClientRect()); // cache — don't measure per-move
const remeasure = () => { rects = items.map(el => el.getBoundingClientRect()); };
const onMove = e => requestAnimationFrame(() => items.forEach((el, i) => {
const c = rects[i].x + rects[i].width / 2; // use clientY + rect.y for a vertical dock
const t = Math.max(0, 1 - Math.abs(e.clientX - c) / 120);
el.style.scale = 1 + t * 0.5;
}));
window.addEventListener("pointermove", onMove);
window.addEventListener("resize", remeasure);
return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("resize", remeasure); };
}, []);
```
**Fits / avoid:**
- ✅ A row/column of **≥ 5 equal icon targets you actually click** — tool palettes, icon rails, app launchers. (KDC candidate: the floor-plan tool palette in `kdc_void_planner``Palette.tsx`, a vertical rail of ~10 tool buttons. Vertical, so drive it off `clientY`.)
-**23 item navs** — the ripple is invisible with so few items.
-**Step indicators / status strips** — those communicate *progress/state*, not click targets. Magnifying them is semantically confusing and fails the bar (it does no job).
**Watch-outs:**
- **Layout thrash.** `getBoundingClientRect()` per `pointermove` forces synchronous layout. Cache the rects (re-measure on `resize`/`scroll`), and batch the writes in `requestAnimationFrame`.
- **Horizontal-only** as written (`clientX`). Swap to `clientY` + `rect.y` for a vertical rail.
- **Neighbours overlap rather than push apart.** `scale` doesn't reflow, so a growing item grows *over* its neighbours instead of displacing them (the real Dock shoves siblings outward). Looks fine with generous gaps/padding; less polished without.
- **`transform-origin`.** For a docked edge, set the origin to that edge (e.g. `transform-origin: bottom` / `left`) so items grow *out of* the dock, not in both directions.
- **Reduced motion + input parity.** Bail when `prefers-reduced-motion` is set; never let the scale affect hit-testing/keyboard order.
**Found:** Paul, May 2026 — a "proximity hack" snippet. Decoded + React-adapted in-session.