diff --git a/README.md b/README.md index b814017..25da8d7 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,10 @@ The system is **two-tier**, so theming is a remap of one layer and never touches Alongside colour: **typography** (Figtree sans + Geist Mono, type scale `text-xs…6xl`, weights, leading, tracking), **spacing** (4px base), **radii**, **elevation** (`shadow-*`), and **motion** -(durations + easings). Token names flatten onto Tailwind v4 `@theme` namespaces so utilities +(durations `micro…slower`, easings incl. `ease-spring`, and ready-made `animate-*` presets - +`pop-in/out`, `shake`, `fade-in/out` - each composing the tokens and gated by consumers with +`motion-reduce:animate-none`; gating is **mandatory** for `shake`, a vestibular trigger). Token +names flatten onto Tailwind v4 `@theme` namespaces so utilities generate directly: `color-*`→`bg-*`/`text-*`, `radius-md`→`rounded-md`, `text-lg`→`text-lg`, `font-sans`→`font-sans`, `shadow-md`→`shadow-md`, and spacing utilities (`p-4`, `gap-2`) derive from a single `--spacing` base. diff --git a/docs/feedback/0015-assert-every-shipped-variant-and-acceptance-property.md b/docs/feedback/0015-assert-every-shipped-variant-and-acceptance-property.md new file mode 100644 index 0000000..6b9c368 --- /dev/null +++ b/docs/feedback/0015-assert-every-shipped-variant-and-acceptance-property.md @@ -0,0 +1,37 @@ +# 0015 - Tests must pin every shipped variant and every acceptance property + +## Symptom + +Spec 0033 shipped five new `animate-*` presets (`pop-in/out`, `shake`, `fade-in/out`) but the +first test pass only asserted the `-in` halves and `shake`, leaving `pop-out` / `fade-out` +unverified - a regression dropping either would have stayed green. Separately, the spec's own +Acceptance listed "the fold stays idempotent (no double-append)" as a deliverable, yet no test +enforced it: the idempotency lived only in `build.mjs`'s prose, so a regression to the old +append-in-place bug (feedback 0003) would have doubled every keyframe while every `toContain` +assertion still passed. Two personas (tester, engineer) independently flagged both gaps on PR #45. + +## Root cause + +The tests asserted the *representative* case, not the *contract*. When a change ships a set of +variants, testing one member reads as "covered" but only pins that member. And an acceptance +bullet that names a property (idempotency) is a promise; if no test encodes it, the promise is +enforced by comment, which rots silently - the exact failure mode of a `toContain` assertion that +can't see a duplicate. + +## Fix + +- Assert BOTH halves of every preset pair and that each `--animate-*` value *composes* its tokens + (`var(--duration-*)` / `var(--ease-*)`), not merely that it is present - looping over the full + variant list rather than hand-picking one. +- Add an occurrence-count guard (`split(needle).length - 1 === 1`) for a keyframe and an + `--animate-*` in the folded preset, turning the idempotency acceptance item into a real test. + +## Learning + +**Test at the granularity the spec promises.** For a set of shipped variants, assert *every* +member, not a representative one - a per-variant loop beats a hand-picked sample. For any property +an acceptance item names (idempotency, ordering, single-occurrence), write the test that *fails* +if it regresses; a `toContain` that passes on both the correct and the doubled output is not +coverage. This is the second coverage-gap review in a row (see +[`0014-interactive-component-test-coverage.md`](0014-interactive-component-test-coverage.md)): +the recurring root is testing the happy representative instead of the whole contract. diff --git a/docs/overview/architecture.md b/docs/overview/architecture.md index 03e4213..ffa83d0 100644 --- a/docs/overview/architecture.md +++ b/docs/overview/architecture.md @@ -34,9 +34,11 @@ the README; Canopy is the whole system. type-safe access. tsup then compiles it in place to `tokens.js` + `tokens.d.ts`. 3. `tailwind-preset.css` - a Tailwind v4 `@theme inline { … }` block (custom SD format `tailwind/preset-v4`) so utilities like `bg-primary` resolve. CSS-first Tailwind v4 means - tokens live in CSS, not a JS config object. It **also ships the design system's overlay-motion** - - `@keyframes` + a `@theme` block of `--animate-dialog-*` vars (composing the `--duration-*` / - `--ease-*` motion tokens) that generate `animate-dialog-*` utilities - folded in from the + tokens live in CSS, not a JS config object. It **also ships the design system's keyframed motion** + - `@keyframes` + a `@theme` block of `--animate-*` vars (composing the `--duration-*` / + `--ease-*` motion tokens) that generate `animate-*` utilities - both the overlay/drawer/sheet + motion (`animate-dialog-*`, `-drawer-*`, `-bottom-sheet-*`) and the generic expressive presets + (`animate-pop-*`, `-shake`, `-fade-*`, spec 0033) - folded in from the hand-authored `preset-motion.css` partial by `build.mjs` (an idempotent single write, like the `tokens.css` fold). Keyframes / `@theme --animate-*` are theme declarations, not utilities, so `@source` could never emit them; shipping them from the preset every consumer imports is what @@ -60,7 +62,7 @@ DTCG sources under `packages/roots/tokens/`, split by concern: | `space.json` | `space.0…32` (4px base) | | `radius.json` | `radius.none…full` | | `shadow.json` | `shadow.sm…xl` | -| `motion.json` | `duration.fast/base/slow`, `ease.standard/emphasized/decelerate` | +| `motion.json` | `duration.micro/fast/base/slow/slower`, `ease.standard/emphasized/decelerate/spring/spring-strong` | ### Tailwind v4 namespace mapping diff --git a/docs/overview/features.md b/docs/overview/features.md index e0587c2..044598a 100644 --- a/docs/overview/features.md +++ b/docs/overview/features.md @@ -50,7 +50,10 @@ theme only; the dark remap + runtime switching are 0004. `text-h2` applies font-size + line-height + font-weight + letter-spacing in one class. Components style against these, not raw scale + weight + leading. - **Spacing / radii / elevation / motion** - `space.0…32` (4px base); `radius.none…full`; - `shadow.sm…xl` (soft, warm); `duration.fast/base/slow` + `ease.standard/emphasized/decelerate`. + `shadow.sm…xl` (soft, warm); `duration.micro/fast/base/slow/slower` + + `ease.standard/emphasized/decelerate/spring/spring-strong`. The Roots preset also ships + token-composed `animate-*` presets - `pop-in/out` (spring), `shake` (error states), + `fade-in/out` - alongside the overlay/drawer/bottom-sheet motion (spec 0033). - **Self-hosted fonts** - Figtree + Geist Mono via `@fontsource-variable/*`, imported in Storybook's global CSS. Roots ships only the family tokens; consumers install @fontsource. - **Tailwind v4 utilities** - token names flatten onto `@theme` namespaces so utilities diff --git a/docs/overview/learnings.md b/docs/overview/learnings.md index 09b9173..4f14f84 100644 --- a/docs/overview/learnings.md +++ b/docs/overview/learnings.md @@ -505,3 +505,19 @@ drive it by **keyboard** in at least one test (not just `click`), and if it supp and uncontrolled use, test **both** (controlled: the display follows the `value` prop, and `onValueChange` fires without the display changing until the parent updates). Don't let an acceptance item be satisfied by the easiest interaction that superficially passes. + +## Test every shipped variant and every acceptance property, not a representative sample + +Spec 0033 shipped five `animate-*` presets but first tested only the `-in` halves, and listed +"the fold stays idempotent" in Acceptance with no test enforcing it - a dropped `-out` preset or a +regressed double-append would both have stayed green, because a `toContain` assertion passes on the +correct AND the doubled/partial output. Caught by the tester + engineer personas. See +[`docs/feedback/0015-assert-every-shipped-variant-and-acceptance-property.md`](../feedback/0015-assert-every-shipped-variant-and-acceptance-property.md). + +**Apply it:** when a change ships a set of variants (preset pairs, token families), assert **every** +member with a per-variant loop, not a hand-picked one - and check composition/value, not mere +presence. When an acceptance item names a property (idempotency, single-occurrence, ordering), +encode the test that **fails** if it regresses (e.g. an occurrence-count guard), because a +presence check can't see a duplicate. This recurs with +[keyboard/controlled coverage (0014)](../feedback/0014-interactive-component-test-coverage.md): the +shared root is testing the happy representative instead of the whole contract. diff --git a/docs/specs/0033-expressive-motion-tokens.md b/docs/specs/0033-expressive-motion-tokens.md new file mode 100644 index 0000000..69f1858 --- /dev/null +++ b/docs/specs/0033-expressive-motion-tokens.md @@ -0,0 +1,119 @@ +# 0033 - Expressive motion tokens (spring easings + motion presets) + +## Problem + +Canopy's motion scale is deliberately small but currently one-note: three durations +(`fast/base/slow`) and three easings (`standard/emphasized/decelerate`) that all **decelerate**. +There is no overshoot/spring curve and no ready-made motion for the small, expressive +interactions components keep re-inventing inline - a badge popping in, a form field shaking on +an invalid submit, a toast fading up. Consumers either hardcode `cubic-bezier`/`ms` values (off +the token seam) or skip the motion entirely. + +transitions.dev (https://github.com/Jakubantalik/transitions.dev) is a well-tuned catalogue of +exactly these micro-interactions. We want its best, broadly-reusable motion **as Canopy tokens + +preset utilities**, curated to fit Canopy's minimal, opinionated scale - not a wholesale import. + +Who it is for: component authors (Branches) and app consumers who want tasteful spring/pop/shake +motion that already composes Canopy's `--duration-*` / `--ease-*` tokens, works out of the box +from the preset every consumer imports, and honours `motion-reduce`. + +## Outcome + +- **Two new easing tokens** in `tokens/motion.json` (adapted from transitions.dev's bounce pair): + - `ease.spring` = `cubic-bezier(0.34, 1.36, 0.64, 1)` - gentle overshoot for playful entrances. + - `ease.spring-strong` = `cubic-bezier(0.34, 2.2, 0.64, 1)` - pronounced bounce, celebratory + accents, use sparingly. +- **Two new duration tokens** extending the scale at both ends (existing `fast/base/slow` + unchanged, so no collision and no consumer breakage): + - `duration.micro` = `80ms` - tiny state changes, staggered children, badge/icon pops. + - `duration.slower` = `480ms` - expressive / spring-driven motion that needs room to settle. +- **Three motion presets** hand-authored in `preset-motion.css` as `@keyframes` + a `@theme` + block of `--animate-*` vars that **compose the tokens** (never hardcoded ms/easing), matching + the existing `animate-dialog-*` / `animate-drawer-*` / `animate-bottom-sheet-*` pattern exactly: + - `animate-pop-in` / `animate-pop-out` - scale + fade; `-in` springs, `-out` settles. + - `animate-shake` - short horizontal shake for error/invalid states. + - `animate-fade-in` / `animate-fade-out` - pure opacity for toasts/content reveal. +- All five outputs stay in lockstep: the new tokens appear in `dist/tokens.css` (`:root` vars), + `dist/tokens.ts` (typed export), and `dist/tailwind-preset.css` (`@theme inline`), and the new + `animate-*` utilities ship folded into `dist/tailwind-preset.css` from `preset-motion.css`. +- `pnpm test` + `pnpm lint` + `pnpm build` stay green in `packages/roots`; README motion section + and `docs/overview/` (`features`, `architecture`) updated to match reality. + +## Scope + +In: + +- `tokens/motion.json` - add `duration.micro`, `duration.slower`, `ease.spring`, + `ease.spring-strong` with `$description`s. +- `preset-motion.css` - add the `pop-*` / `shake` / `fade-*` keyframes and their token-composed + `--animate-*` theme vars; extend the file's header comment to cover the new presets. +- `tokens.test.ts` - assert the new tokens land in each output and the new `animate-*` utilities + ship token-composed (grep the built preset, per the existing motion test). +- README motion section + `docs/overview/features.md` / `architecture.md`. + +Out: + +- `success-check` and other **multi-element composites** from transitions.dev (fade+rotate+blur+ + bob+SVG-path-draw). A single `--animate-*` utility is one keyframe timeline on one element; the + check's path-draw needs `stroke-dashoffset` on a nested SVG path. That is component motion, not + a token - a future Branches component can compose these tokens for it. Noted as future work. +- Distance / scale / blur helper tokens from transitions.dev. Canopy authors those inline per + component today; adding a parallel token family is a separate decision, not this spec. +- Changing or renaming any existing token (`fast/base/slow`, `standard/emphasized/decelerate`) + or existing `animate-dialog-*` / `-drawer-*` / `-bottom-sheet-*` utility. Purely additive. +- Retrofitting existing components to use the new presets (each is its own small follow-up). + +## Approach + +**Tokens.** Append to the existing DTCG groups in `motion.json`. Durations keep file order +low-to-high (`micro`, `fast`, `base`, `slow`, `slower`); easings append after `decelerate`. The +Style Dictionary pipeline already runs `cubicBezier/css` + duration transforms, so no config or +transform change is needed - the new tokens flow through the same three web outputs automatically. + +**Naming.** Canopy easings are named by **intent** (`standard`/`emphasized`/`decelerate`), so +transitions.dev's `bounce` becomes `spring` (subtle) / `spring-strong` (pronounced). Durations +keep Canopy's t-shirt scale; transitions.dev's own `fast`(250)/`slow`(400) values are **not** +reused - they would collide with Canopy's `fast`(120)/`slow`(320). We map intent, not literals. + +**Presets.** Mirror the established partial exactly - a pair of `@keyframes` (`-in`/`-out`) plus +`@theme { --animate-: var(--duration-*) var(--ease-*); }`. Choices: + +- `pop-in`: `scale(0.9)`+`opacity:0` -> `scale(1)`+`opacity:1`, `var(--duration-base)` + `var(--ease-spring)` - the flagship showcase of the new spring curve (badges, popovers, menus, + tooltips). +- `pop-out`: reverse to `scale(0.96)`+`opacity:0`, `var(--duration-fast)` `var(--ease-standard)` - + exits settle rather than bounce, matching Canopy's existing `*-out` convention (dialog/drawer + exits use `--ease-standard`). +- `shake`: 3 short `translateX` cycles (`+/- 4px`), `var(--duration-slow)` `var(--ease-standard)` - + for `aria-invalid` form fields; the amplitude stays small and finite so it reads as feedback, + not decoration. +- `fade-in` / `fade-out`: pure `opacity`, `var(--duration-base)`/`var(--duration-fast)` + `var(--ease-standard)` - the most reusable primitive (toasts, content). + +Every consumer gates these with `motion-reduce:animate-none` at the call site (documented in the +README), same as the dialog/drawer motion - the preset ships the motion; the component opts in. + +**Testing.** Follow the existing motion test: build the preset in-test and grep the **built rule** +(the literal-class learning - keyframes/`@theme --animate-*` can never come from `@source`), and +assert each new `--animate-*` value contains the expected `var(--duration-*)`/`var(--ease-*)` so +motion stays token-driven, not hardcoded. Assert the four new tokens appear in `tokens.css`, +`tokens.ts`, and the preset's `@theme inline`. + +## Acceptance + +- [ ] `motion.json` defines `duration.micro` (80ms), `duration.slower` (480ms), `ease.spring` + (`cubic-bezier(0.34, 1.36, 0.64, 1)`), `ease.spring-strong` + (`cubic-bezier(0.34, 2.2, 0.64, 1)`) with `$description`s; existing tokens unchanged. +- [ ] `pnpm build` emits the four new tokens into `dist/tokens.css`, `dist/tokens.ts`, and + `dist/tailwind-preset.css` (`@theme inline`). +- [ ] `preset-motion.css` adds `@keyframes` for pop-in/out, shake, fade-in/out and `--animate-*` + theme vars that COMPOSE `--duration-*` / `--ease-*` (no hardcoded ms/easing); they ship + folded into `dist/tailwind-preset.css`, and existing `animate-dialog-*`/`-drawer-*`/ + `-bottom-sheet-*` utilities are untouched. +- [ ] `tokens.test.ts` asserts: the four new tokens in each output; `animate-pop-in` composes + `var(--duration-base)` + `var(--ease-spring)`; `animate-shake` and `animate-fade-in` ship + token-composed; the fold stays idempotent (no double-append). +- [ ] `pnpm test` + `pnpm lint` green in `packages/roots`. +- [ ] README motion section documents the new easings, durations, and `animate-*` utilities + (with the `motion-reduce` gating note); `docs/overview/features.md` + `architecture.md` + updated to match. `success-check` listed as future component-level work. diff --git a/packages/roots/preset-motion.css b/packages/roots/preset-motion.css index 2113e9f..7c39b02 100644 --- a/packages/roots/preset-motion.css +++ b/packages/roots/preset-motion.css @@ -1,5 +1,5 @@ /** - * Roots overlay-motion partial (the design system's keyframed motion). + * Roots motion partial - the design system's keyframed motion (overlay + expressive). * * `@keyframes` and `@theme --animate-*` are THEME declarations, not utilities, so Tailwind's * `@source` scanner can NEVER emit them - they must ship from CSS every consumer already imports. @@ -15,7 +15,17 @@ * (`animate-drawer-in` / `-out`), first consumed by SideNav (spec 0026): the mobile drawer slides in * from the left edge and back out, gated the same way. It further carries the bottom-sheet slide * (`animate-bottom-sheet-in` / `-out`), first consumed by ResponsiveDialog (spec 0031): the mobile - * dialog form slides up from the bottom edge and back down, gated the same way. + * dialog form slides up from the bottom edge and back down, gated the same way. The partial now + * ALSO carries generic expressive presets, not tied to one component: `animate-pop-in` / `-out` + * (spring scale + fade for badges, popovers, menus), `animate-shake` (a short horizontal shake for + * error / invalid states), and `animate-fade-in` / `-out` (pure opacity for toasts and content + * reveal). Each composes the `--duration-*` / `--ease-*` tokens and is gated by consumers with + * `motion-reduce:animate-none`, the same as the motion above. + * + * REDUCED MOTION IS MANDATORY, not optional, for `animate-shake`: a repeated translate is a + * vestibular trigger, so any consumer applying it to an error / `aria-invalid` state MUST pair it + * with `motion-reduce:animate-none`. The overlay/pop/fade presets are also gated by convention, + * but shake is the one that can actively harm - never ship it ungated. */ @keyframes dialog-overlay-in { from { @@ -85,6 +95,56 @@ transform: translateY(100%); } } +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes pop-out { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-4px); + } + 40%, + 80% { + transform: translateX(4px); + } +} +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} @theme { --animate-dialog-overlay-in: dialog-overlay-in var(--duration-slow) var(--ease-decelerate); @@ -95,4 +155,9 @@ --animate-drawer-out: drawer-out var(--duration-base) var(--ease-standard); --animate-bottom-sheet-in: bottom-sheet-in var(--duration-slow) var(--ease-decelerate); --animate-bottom-sheet-out: bottom-sheet-out var(--duration-base) var(--ease-standard); + --animate-pop-in: pop-in var(--duration-base) var(--ease-spring); + --animate-pop-out: pop-out var(--duration-fast) var(--ease-standard); + --animate-shake: shake var(--duration-slow) var(--ease-standard); + --animate-fade-in: fade-in var(--duration-base) var(--ease-standard); + --animate-fade-out: fade-out var(--duration-fast) var(--ease-standard); } diff --git a/packages/roots/tokens.test.ts b/packages/roots/tokens.test.ts index 583abf6..4b17c88 100644 --- a/packages/roots/tokens.test.ts +++ b/packages/roots/tokens.test.ts @@ -72,6 +72,11 @@ describe('Roots token outputs - Tailwind v4 namespaces', () => { expect(preset).toContain('--shadow-md: var(--shadow-md)'); expect(preset).toContain('--font-sans: var(--font-sans)'); expect(preset).toContain('--ease-standard: var(--ease-standard)'); + // Expressive motion tokens (0033) reach the @theme inline block too. + expect(preset).toContain('--duration-micro: var(--duration-micro)'); + expect(preset).toContain('--duration-slower: var(--duration-slower)'); + expect(preset).toContain('--ease-spring: var(--ease-spring)'); + expect(preset).toContain('--ease-spring-strong: var(--ease-spring-strong)'); // Typography sub-namespaces (drive leading-*/tracking-*/font-* utilities). expect(preset).toContain('--leading-snug: var(--leading-snug)'); expect(preset).toContain('--tracking-tight: var(--tracking-tight)'); @@ -97,6 +102,56 @@ describe('Roots token outputs - Tailwind v4 namespaces', () => { expect(preset).toContain('--animate-drawer-in:'); const drawerIn = /--animate-drawer-in:\s*([^;]+);/.exec(preset)?.[1] ?? ''; expect(drawerIn).toContain('var(--duration-slow)'); + + // The same partial now carries the generic expressive presets (0033) - pop/shake/fade. + // Assert BOTH halves of each pair ship as keyframes and that every animate value composes + // the motion tokens (never hardcoded ms/easing) - the -out halves deliberately settle + // (--ease-standard) rather than spring, exactly the intent a test should pin. + for (const kf of ['pop-in', 'pop-out', 'shake', 'fade-in', 'fade-out']) { + expect(preset).toContain(`@keyframes ${kf}`); + } + const animate = (name: string) => + new RegExp(`--animate-${name}:\\s*([^;]+);`).exec(preset)?.[1] ?? ''; + // pop-in springs; pop-out settles. + expect(animate('pop-in')).toContain('var(--duration-base)'); + expect(animate('pop-in')).toContain('var(--ease-spring)'); + expect(animate('pop-out')).toContain('var(--duration-fast)'); + expect(animate('pop-out')).toContain('var(--ease-standard)'); + // shake reads as feedback, not a spring. + expect(animate('shake')).toContain('var(--duration-slow)'); + expect(animate('shake')).toContain('var(--ease-standard)'); + // fade-in / fade-out - assert composition, not mere presence. + expect(animate('fade-in')).toContain('var(--duration-base)'); + expect(animate('fade-in')).toContain('var(--ease-standard)'); + expect(animate('fade-out')).toContain('var(--duration-fast)'); + expect(animate('fade-out')).toContain('var(--ease-standard)'); + + // The fold is idempotent (feedback 0003): build.mjs re-reads the fresh Style-Dictionary + // preset before appending, so keyframes/--animate-* appear EXACTLY once. A regression to the + // old append-in-place bug would double them while every toContain above still passed - so + // pin the count, which is the property spec 0033's acceptance calls out. + const occurrences = (needle: string) => preset.split(needle).length - 1; + expect(occurrences('@keyframes pop-in')).toBe(1); + expect(occurrences('--animate-pop-in:')).toBe(1); + expect(occurrences('@keyframes dialog-overlay-in')).toBe(1); + }); +}); + +describe('Roots motion outputs - expressive tokens (0033)', () => { + it('tokens.css declares the new spring easings and micro/slower durations', () => { + const css = read('tokens.css'); + expect(css).toContain('--duration-micro: 80ms'); + expect(css).toContain('--duration-slower: 480ms'); + expect(css).toContain('--ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1)'); + expect(css).toContain('--ease-spring-strong: cubic-bezier(0.34, 2.2, 0.64, 1)'); + }); + + it('typed TS export exposes the new motion tokens with their literal values', async () => { + const { tokens } = await import('./dist/tokens.js'); + expect(tokens['duration-micro']).toBe('80ms'); + expect(tokens['duration-slower']).toBe('480ms'); + expect(tokens['ease-spring']).toBe('cubic-bezier(0.34, 1.36, 0.64, 1)'); + expect(tokens['ease-spring-strong']).toBe('cubic-bezier(0.34, 2.2, 0.64, 1)'); }); }); diff --git a/packages/roots/tokens/motion.json b/packages/roots/tokens/motion.json index 9a81193..e329310 100644 --- a/packages/roots/tokens/motion.json +++ b/packages/roots/tokens/motion.json @@ -1,5 +1,10 @@ { "duration": { + "micro": { + "$value": "80ms", + "$type": "duration", + "$description": "Tiny state changes, staggered children, badge/icon pops." + }, "fast": { "$value": "120ms", "$type": "duration", @@ -14,6 +19,11 @@ "$value": "320ms", "$type": "duration", "$description": "Larger surfaces (overlays, drawers)." + }, + "slower": { + "$value": "480ms", + "$type": "duration", + "$description": "Expressive / spring-driven motion that needs room to settle." } }, "ease": { @@ -31,6 +41,16 @@ "$value": "cubic-bezier(0, 0, 0, 1)", "$type": "cubicBezier", "$description": "Decelerate - elements entering and settling." + }, + "spring": { + "$value": "cubic-bezier(0.34, 1.36, 0.64, 1)", + "$type": "cubicBezier", + "$description": "Gentle overshoot for playful entrances (badges, popovers)." + }, + "spring-strong": { + "$value": "cubic-bezier(0.34, 2.2, 0.64, 1)", + "$type": "cubicBezier", + "$description": "Pronounced bounce; celebratory accents, use sparingly." } } }