← alle entries

motion

FC-0006motionprimitivev1.0.0

Spring-wrapper bovenop Framer Motion: tokens in, physics intern; projection, rubberband en de site/app-schaal (MotionScaleProvider).

zelfde component — andere tokens

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;