# Apple-style transition recipes

A recipe book of macOS/iOS-flavored motion patterns, written as reusable CSS/JS techniques rather than clones of any specific product. Every recipe includes a `prefers-reduced-motion` variant — treat that as non-negotiable, not a nice-to-have.

Shared tokens referenced below live in [`../examples/easing-tokens.css`](../examples/easing-tokens.css).

---

## 1. macOS-style window opening

The classic "sheet drops in" or "window un-minimizes" feeling: scale up from slightly-smaller-than-final, fade in, settle with a gentle overshoot-free spring — never a hard linear pop.

**Ingredients**
- Start state: `opacity: 0`, `transform: scale(0.96) translateY(10–16px)`, optional `filter: blur(4–8px)`.
- End state: full opacity, `scale(1)`, no blur.
- Easing: a spring-like cubic-bezier such as `cubic-bezier(0.16, 1, 0.3, 1)` — fast start, soft landing, no bounce past 100%.
- Duration: 300–450ms. Shorter reads as a snap; longer reads as sluggish.

```css
.window-surface {
  animation: window-open 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
}

@keyframes window-open {
  from { opacity: 0; transform: scale(0.96) translateY(12px); filter: blur(6px); }
  to   { opacity: 1; transform: scale(1) translateY(0); filter: blur(0); }
}
```

**Reduced motion:** drop straight to the end state, or keep only the opacity fade at 1ms transform.

---

## 2. Hero / zoom transitions

Used when one element visually "becomes" another — e.g. a thumbnail expanding into a detail view, or an icon expanding into a window. The key is **shared geometry**: the incoming element should start at the outgoing element's size/position and animate to its own natural layout.

**Ingredients**
- Capture the trigger element's bounding rect before transitioning.
- Position the destination element absolutely at that rect, then animate `top/left/width/height` (or a `transform: scale()` + `translate()` pair, which is cheaper) to its natural position.
- Cross-fade any content that doesn't map 1:1 (e.g. icon → full toolbar).
- Easing: `cubic-bezier(0.22, 1, 0.36, 1)` reads as confident and quick without feeling rushed.

```js
const startRect = trigger.getBoundingClientRect();
const endRect = destination.getBoundingClientRect();
const dx = startRect.left - endRect.left;
const dy = startRect.top - endRect.top;
const scaleX = startRect.width / endRect.width;
const scaleY = startRect.height / endRect.height;

destination.animate(
  [
    { transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`, opacity: 0.6 },
    { transform: "translate(0, 0) scale(1, 1)", opacity: 1 },
  ],
  { duration: 360, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" }
);
```

**Reduced motion:** skip the geometry animation; cross-fade only (150ms opacity).

---

## 3. Liquid / glass edge highlight

A thin rim of light that seems to catch on an element's border, suggesting a glass-like surface rather than flat color. Works well combined with a soft ambient glow behind it (see the composer's `.composer__glow` in `src/styles.css`).

**Ingredients**
- A pseudo-element sized slightly larger than the parent (`inset: -1px`), with a `conic-gradient` border-like sweep.
- Rotate the gradient continuously and clip to the border via `border-radius` + `overflow: hidden` on a wrapping layer.
- Keep the bright arc narrow (30–50deg of the 360deg gradient) so it reads as a highlight, not a spinning ring.

```css
.glass-edge::before {
  content: "";
  position: absolute;
  inset: -50%;
  background: conic-gradient(from 0deg, transparent 0 260deg, #2dd4bf 300deg, transparent 340deg);
  animation: edge-rotate 2.6s linear infinite;
}

@keyframes edge-rotate {
  to { transform: rotate(360deg); }
}
```

**Reduced motion:** freeze the gradient at a fixed angle, or hide it entirely and keep only a static 1px accent border.

---

## 4. Command palette bloom

A command palette (⌘K-style) should feel like it *blooms* from its trigger point rather than sliding in from off-screen or simply fading. Scale from a flattened vertical state, anchored at `transform-origin: top`.

**Ingredients**
- `transform-origin: top center` (or wherever it's anchored — top of a search bar, etc.).
- Start: `scaleY(0.6) scaleX(0.96)`, `opacity: 0`.
- End: `scaleY(1) scaleX(1)`, `opacity: 1`.
- Pair with a brief backdrop dim/blur fade-in if the palette is modal.
- Keep list items themselves static on open — stagger-animating every row usually reads as noisy rather than premium.

```css
.command-palette {
  transform-origin: top center;
  animation: palette-bloom 240ms cubic-bezier(0.16, 1, 0.3, 1) both;
}

@keyframes palette-bloom {
  from { opacity: 0; transform: scaleY(0.6) scaleX(0.96); }
  to   { opacity: 1; transform: scaleY(1) scaleX(1); }
}
```

**Reduced motion:** fade only, no scale.

---

## Reduced-motion variants — the pattern, generalized

Every recipe above should degrade the same way:

1. Wrap decorative/looping animations (breathing glow, rotating sweep, shimmer) in `@media (prefers-reduced-motion: reduce)` and set `animation: none`.
2. Wrap entrance transforms (scale/translate/blur) and collapse them to their end state instantly or with a 1ms transition — don't just delete the animation and leave the start state stuck.
3. Never remove opacity fades entirely; a very short fade (100–150ms) is rarely disorienting and helps avoid jarring pops.

See [`../examples/reduced-motion-snippet.css`](../examples/reduced-motion-snippet.css) for a drop-in guard block.
