motion
FC-0006motionprimitivev1.0.0Spring-wrapper bovenop Framer Motion: tokens in, physics intern; projection, rubberband en de site/app-schaal (MotionScaleProvider).
Boven: springPer(x: default, rotate: smooth) — verschillende eindtijden, bewust. Onder: playful via useSpringTransition (schaal uit de context).
primitives/motion/spring.ts
/**
* Spring-wrapper bovenop Framer Motion — de enige plek waar spring-configuratie
* vandaan komt. Componenten praten in tokens ({ duration, bounce } blijft
* intern); Framer Motion levert de physics, retargeting en interruptie.
*
* TWEE SOORTEN ANIMATIE, TWEE CONTRACTEN (REGISTRY_PLAN §2.2):
* - `spring()` — GETRIGGERD (klik, state-wissel, enter/exit). Neemt een
* token en valt daarmee per constructie binnen het
* duurbudget van zijn schaal.
* - `releaseSpring()` — GEBAAR-GEDREVEN (loslaten na slepen/swipen). Erft de
* snelheid van het gebaar; de werkelijke settle-tijd is
* daardoor per definitie onbekend en valt buiten het
* duurbudget. Dat is de bedoeling — nooit hard-cutten
* naar 0.
*
* RETARGETING: Framer Motion gebruikt bij een onderbroken spring automatisch de
* momentane snelheid als beginsnelheid van de nieuwe animatie (duration/bounce
* wordt intern naar physics vertaald). Expliciete `velocity` is alleen nodig
* wanneer je een verse `animate()` start vanaf een kaal getal in plaats van een
* MotionValue.
*
* 2D: Framer animeert elke property als eigen spring — x en y zijn dus altijd
* al gesplitst. Geef nooit één samengestelde transform-string door.
*/
import type { Transition } from "framer-motion";
import {
MOTION_MASS,
MOTION_TOKENS,
type MotionMassName,
type MotionScale,
type MotionTokenName,
} from "../../tokens/motion-tokens";
export interface SpringOptions {
/** Welke tempo-schaal; default "site". In een app/dashboard: "app" (of via MotionScaleProvider). */
scale?: MotionScale;
/** Massa per contenttype: "content" (0.85), "system" (1.15) of een eigen factor. Schaalt alleen de duur; de curve-vorm blijft identiek. */
mass?: MotionMassName | number;
delay?: number;
}
function resolveDuration(token: MotionTokenName, opts?: SpringOptions): number {
const base = MOTION_TOKENS[token].durations[opts?.scale ?? "site"];
const mass =
typeof opts?.mass === "number" ? opts.mass : opts?.mass ? MOTION_MASS[opts.mass] : 1;
return base * mass;
}
/** Getriggerde animatie: token → Framer-transition. */
export function spring(token: MotionTokenName, opts?: SpringOptions): Transition {
return {
type: "spring",
duration: resolveDuration(token, opts),
bounce: MOTION_TOKENS[token].bounce,
delay: opts?.delay,
};
}
export interface ReleaseSpringOptions extends SpringOptions {
/** Beginsnelheid in eenheden/s — alleen nodig bij animate() vanaf een kaal getal; MotionValues dragen hun snelheid zelf. */
velocity?: number;
}
/**
* Gebaar-gedreven animatie (na loslaten). Default-token "snappy": het gebaar
* zelf heeft momentum, dus een beetje veer is hier juist (WWDC18: Music swipet
* met 80% damping weg, maar tapt met 100%).
*/
export function releaseSpring(
token: MotionTokenName = "snappy",
opts?: ReleaseSpringOptions,
): Transition {
return {
type: "spring",
duration: resolveDuration(token, opts),
bounce: MOTION_TOKENS[token].bounce,
velocity: opts?.velocity,
delay: opts?.delay,
};
}
/**
* Per-property transitions in één keer, voor multi-property animaties die NIET
* op hetzelfde moment uitgeanimeerd hoeven te zijn (dat is juist goed — de
* iOS-app-launch bestaat uit meerdere springs met verschillende eindtijden).
*
* transition: springPer({ opacity: "instant", y: "default" }, { scale })
*/
export function springPer(
tokens: Partial<Record<string, MotionTokenName>>,
opts?: SpringOptions,
): Record<string, Transition> {
const result: Record<string, Transition> = {};
for (const [property, token] of Object.entries(tokens)) {
if (token) result[property] = spring(token, opts);
}
return result;
}
primitives/motion/projection.ts
/**
* Projection — waar komt een geflickt element op basis van zijn snelheid tot
* stilstand? (WWDC18 "Designing Fluid Interfaces": de FaceTime-PIP kiest niet
* de dichtstbijzijnde hoek, maar de hoek het dichtst bij de GEPROJECTEERDE
* eindpositie.)
*
* Puur en dimensieloos: werkt voor posities, maar net zo goed voor scale of
* rotatie. 2D = twee keer aanroepen (x en y apart, zoals alles hier).
*/
/** UIScrollView.DecelerationRate.normal — het standaard scroll-uitrolgevoel. */
export const DECELERATION_NORMAL = 0.998;
/** UIScrollView.DecelerationRate.fast — voor korte, besliste uitrol (pagers). */
export const DECELERATION_FAST = 0.99;
/**
* Geprojecteerde eindwaarde. `velocity` in eenheden per seconde (zoals
* MotionValue.getVelocity() teruggeeft); de formule rekent per milliseconde,
* vandaar de deling door 1000.
*/
export function project(
position: number,
velocity: number,
deceleration: number = DECELERATION_NORMAL,
): number {
return position + (velocity / 1000) * (deceleration / (1 - deceleration));
}
/**
* Kies uit vaste rustpunten (hoeken, pagina-offsets, snap-posities) het punt
* dat het dichtst bij de geprojecteerde eindpositie ligt. Leeg array → de
* geprojecteerde waarde zelf (vrij uitrollen).
*/
export function projectToNearest(
position: number,
velocity: number,
restingPoints: readonly number[],
deceleration: number = DECELERATION_NORMAL,
): number {
const projected = project(position, velocity, deceleration);
if (restingPoints.length === 0) return projected;
let best = restingPoints[0] as number;
for (const point of restingPoints) {
if (Math.abs(point - projected) < Math.abs(best - projected)) best = point;
}
return best;
}
/**
* Rubberband — verplaatsing voorbij een grens telt steeds minder mee (Apple's
* klassieke asymptoot). `overshoot` is hoe ver voorbij de grens getrokken is;
* `limit` is hoe ver het element maximaal mee mag geven (bv. 0.5 × breedte).
*/
export function rubberband(overshoot: number, limit: number, coefficient = 0.55): number {
if (limit <= 0) return 0;
const abs = Math.abs(overshoot);
const resisted = (1 - 1 / ((abs * coefficient) / limit + 1)) * limit;
return Math.sign(overshoot) * resisted;
}
primitives/motion/motion-scale.tsx
/**
* MotionScaleProvider — één component die de tempo-schaal voor CSS én JS
* tegelijk zet, zodat die twee nooit uit elkaar kunnen lopen.
*
* - CSS: rendert een `display: contents`-wrapper met class `motion-app`
* (custom properties erven daar gewoon doorheen), zodat de kortere duraties
* uit tokens/motion.css gelden.
* - JS: React-context die `useSpringTransition` uitleest.
*
* Gebruik: om de hele app (OS/portal: in de root-layout) of om de
* /admin-boom van een gegenereerd klantproject. Buiten een provider geldt de
* site-schaal — het juiste default voor marketingpagina's.
*/
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { useReducedMotion } from "framer-motion";
import type { Transition } from "framer-motion";
import type { MotionScale, MotionTokenName } from "../../tokens/motion-tokens";
import { spring, type SpringOptions } from "./spring";
const MotionScaleContext = createContext<MotionScale>("site");
export function MotionScaleProvider({
scale,
children,
}: {
scale: MotionScale;
children: ReactNode;
}) {
return (
<MotionScaleContext.Provider value={scale}>
<div style={{ display: "contents" }} className={scale === "app" ? "motion-app" : undefined}>
{children}
</div>
</MotionScaleContext.Provider>
);
}
export function useMotionScale(): MotionScale {
return useContext(MotionScaleContext);
}
/**
* Token → transition, met de schaal uit de context en reduced-motion ingebouwd:
* bij `prefers-reduced-motion` wordt de beweging een directe zet (duration 0),
* niet een tragere variant — het gedrag blijft, de beweging verdwijnt.
* Componenten met een betekenisvol alternatief (bv. crossfade i.p.v. slide)
* mogen zelf iets beters doen; dit is het veilige default.
*/
export function useSpringTransition(
token: MotionTokenName,
opts?: Omit<SpringOptions, "scale">,
): Transition {
const scale = useMotionScale();
const reduced = useReducedMotion();
if (reduced) return { type: "tween", duration: 0 };
return spring(token, { ...opts, scale });
}
primitives/motion/index.ts
/**
* FORMA spring-wrapper — publieke oppervlakte.
* Copy-in: kopieer `primitives/motion/` + `tokens/` samen naar het project
* (de wrapper importeert `../../tokens/motion-tokens`).
* Peer-dependency: framer-motion.
*/
export { spring, releaseSpring, springPer } from "./spring";
export type { SpringOptions, ReleaseSpringOptions } from "./spring";
export {
project,
projectToNearest,
rubberband,
DECELERATION_NORMAL,
DECELERATION_FAST,
} from "./projection";
export { MotionScaleProvider, useMotionScale, useSpringTransition } from "./motion-scale";
export {
MOTION_TOKENS,
MOTION_MASS,
MAX_BOUNCE,
} from "../../tokens/motion-tokens";
export type {
MotionScale,
MotionTokenName,
MotionToken,
MotionMassName,
} from "../../tokens/motion-tokens";
tokens/motion.css
/**
* FORMA motion-tokens — GEGENEREERD, niet met de hand bewerken.
* Bron: tokens/generate-motion.mjs · draai `node tokens/generate-motion.mjs`.
*
* Twee vars per token: de curve en de duur.
* transition: transform var(--motion-default-duration) var(--motion-default);
*
* Twee schalen, één karakter:
* :root site-schaal (marketingsites — Apple-ritme)
* .motion-app app-schaal (FORMA OS, portal, /admin-dashboards) — zet de
* class op body of op de dashboard-layout; alle duraties
* vallen dan binnen CLAUDE.md §9 (~150-300ms). De curve
* blijft identiek; alleen de klok verandert.
*
* Massa schaal je exact met calc() op de duur — de vorm blijft identiek:
* transition-duration: calc(var(--motion-smooth-duration) * var(--motion-mass-system));
*/
:root {
/* ── Curves (een per bounce-waarde; alle tokens verwijzen hiernaar) ── */
/* zeta 1.00 · geen overshoot */
--motion-curve-0: linear(0.0000 0.00%, 0.0190 3.13%, 0.0663 6.25%, 0.1306 9.38%, 0.2040 12.50%, 0.2807 15.63%, 0.3570 18.75%, 0.4303 21.88%, 0.4991 25.00%, 0.5624 28.13%, 0.6200 31.25%, 0.6717 34.38%, 0.7177 37.50%, 0.7583 40.63%, 0.7940 43.75%, 0.8252 46.88%, 0.8522 50.00%, 0.8757 53.13%, 0.8958 56.25%, 0.9132 59.38%, 0.9280 62.50%, 0.9407 65.63%, 0.9515 68.75%, 0.9607 71.88%, 0.9685 75.00%, 0.9752 78.13%, 0.9807 81.25%, 0.9855 84.38%, 0.9895 87.50%, 0.9928 90.63%, 0.9956 93.75%, 0.9980 96.88%, 1.0000 100.00%);
/* zeta 0.85 · overshoot 0.6% */
--motion-curve-15: linear(0.0000 0.00%, 0.0041 2.08%, 0.0157 4.17%, 0.0335 6.25%, 0.0566 8.33%, 0.0838 10.42%, 0.1145 12.50%, 0.1479 14.58%, 0.1832 16.67%, 0.2200 18.75%, 0.2578 20.83%, 0.2960 22.92%, 0.3343 25.00%, 0.3725 27.08%, 0.4101 29.17%, 0.4471 31.25%, 0.4831 33.33%, 0.5181 35.42%, 0.5519 37.50%, 0.5844 39.58%, 0.6156 41.67%, 0.6454 43.75%, 0.6737 45.83%, 0.7005 47.92%, 0.7260 50.00%, 0.7499 52.08%, 0.7725 54.17%, 0.7936 56.25%, 0.8134 58.33%, 0.8319 60.42%, 0.8491 62.50%, 0.8650 64.58%, 0.8798 66.67%, 0.8935 68.75%, 0.9061 70.83%, 0.9177 72.92%, 0.9283 75.00%, 0.9380 77.08%, 0.9469 79.17%, 0.9550 81.25%, 0.9624 83.33%, 0.9690 85.42%, 0.9750 87.50%, 0.9804 89.58%, 0.9853 91.67%, 0.9896 93.75%, 0.9935 95.83%, 0.9970 97.92%, 1.0000 100.00%);
/* zeta 0.70 · overshoot 4.6% */
--motion-curve-30: linear(0.0000 0.00%, 0.0087 2.08%, 0.0326 4.17%, 0.0687 6.25%, 0.1142 8.33%, 0.1666 10.42%, 0.2240 12.50%, 0.2844 14.58%, 0.3463 16.67%, 0.4084 18.75%, 0.4695 20.83%, 0.5289 22.92%, 0.5857 25.00%, 0.6396 27.08%, 0.6900 29.17%, 0.7367 31.25%, 0.7796 33.33%, 0.8186 35.42%, 0.8537 37.50%, 0.8850 39.58%, 0.9127 41.67%, 0.9368 43.75%, 0.9576 45.83%, 0.9754 47.92%, 0.9903 50.00%, 1.0026 52.08%, 1.0125 54.17%, 1.0204 56.25%, 1.0263 58.33%, 1.0306 60.42%, 1.0334 62.50%, 1.0351 64.58%, 1.0356 66.67%, 1.0353 68.75%, 1.0343 70.83%, 1.0327 72.92%, 1.0306 75.00%, 1.0283 77.08%, 1.0256 79.17%, 1.0229 81.25%, 1.0200 83.33%, 1.0172 85.42%, 1.0144 87.50%, 1.0116 89.58%, 1.0090 91.67%, 1.0065 93.75%, 1.0042 95.83%, 1.0020 97.92%, 1.0000 100.00%);
/* ── Tokens ── */
/* hover, focus, kleurwissels */
--motion-instant: var(--motion-curve-0);
/* werkpaard — alles zonder momentum */
--motion-default: var(--motion-curve-0);
/* grotere elementen, panelen, secties */
--motion-smooth: var(--motion-curve-0);
/* popups: dialogen, popovers, menu's (schaal + fade vanuit het anker) */
--motion-pop: var(--motion-curve-15);
/* lichte reactie op een gebaar */
--motion-snappy: var(--motion-curve-15);
/* alleen na een gebaar met momentum */
--motion-playful: var(--motion-curve-30);
/* ── Duraties: site-schaal (default) ── */
--motion-instant-duration: 211ms;
--motion-default-duration: 370ms;
--motion-smooth-duration: 528ms;
--motion-pop-duration: 213ms;
--motion-snappy-duration: 284ms;
--motion-playful-duration: 523ms;
/* ── Massa per contenttype ── */
/* foto's, kaarten — conceptueel lichter */
--motion-mass-content: 0.85;
/* panelen, navigatie — zwaarder */
--motion-mass-system: 1.15;
}
/* ── Duraties: app-schaal ── */
.motion-app {
--motion-instant-duration: 169ms;
--motion-default-duration: 243ms;
--motion-smooth-duration: 296ms;
--motion-pop-duration: 156ms;
--motion-snappy-duration: 213ms;
--motion-playful-duration: 272ms;
}
/**
* Reduced motion: 1ms, niet 0 — een duur van nul slaat transitionend over en
* breekt code die op het einde van een transitie wacht. De beweging verdwijnt,
* het gedrag niet. Geldt voor beide schalen (de .motion-app-selector staat
* erbij zodat hij ook daar wint op specificiteit).
*/
@media (prefers-reduced-motion: reduce) {
:root,
.motion-app {
--motion-instant-duration: 1ms;
--motion-default-duration: 1ms;
--motion-smooth-duration: 1ms;
--motion-pop-duration: 1ms;
--motion-snappy-duration: 1ms;
--motion-playful-duration: 1ms;
}
}
tokens/motion-tokens.ts
/**
* FORMA motion-tokens (TypeScript-kant) — GEGENEREERD, niet met de hand bewerken.
* Bron: tokens/generate-motion.mjs · draai `node tokens/generate-motion.mjs`.
* Zelfde bron van waarheid als motion.css: duraties zijn de PARAMETERS
* (waargenomen duur in seconden), niet de settle-tijden uit de CSS.
*/
export type MotionScale = "site" | "app";
export type MotionTokenName = "instant" | "default" | "smooth" | "pop" | "snappy" | "playful";
export interface MotionToken {
bounce: number;
durations: Record<MotionScale, number>;
}
export const MOTION_TOKENS: Record<MotionTokenName, MotionToken> = {
/** hover, focus, kleurwissels */
instant: { bounce: 0, durations: { site: 0.2, app: 0.16 } },
/** werkpaard — alles zonder momentum */
default: { bounce: 0, durations: { site: 0.35, app: 0.23 } },
/** grotere elementen, panelen, secties */
smooth: { bounce: 0, durations: { site: 0.5, app: 0.28 } },
/** popups: dialogen, popovers, menu's (schaal + fade vanuit het anker) */
pop: { bounce: 0.15, durations: { site: 0.3, app: 0.22 } },
/** lichte reactie op een gebaar */
snappy: { bounce: 0.15, durations: { site: 0.4, app: 0.3 } },
/** alleen na een gebaar met momentum */
playful: { bounce: 0.3, durations: { site: 0.5, app: 0.26 } },
} as const;
export type MotionMassName = "content" | "system";
/** Duur-multipliers per contenttype; de curve-vorm blijft identiek. */
export const MOTION_MASS: Record<MotionMassName, number> = {
/** foto's, kaarten — conceptueel lichter */
content: 0.85,
/** panelen, navigatie — zwaarder */
system: 1.15,
} as const;
/** Plafond uit de brainstorm §3.2 — boven 0.3 is bounce te druk voor UI. */
export const MAX_BOUNCE = 0.3;