From 4c8938214060e8fd1204e5058148e5516b57cc5b Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 7 Mar 2026 09:45:07 +1100 Subject: [PATCH 1/2] setup numbermorph --- packages/torph/src/index.ts | 5 +- .../torph/src/lib/number-morph/animate.ts | 120 +++++++++ .../torph/src/lib/number-morph/controller.ts | 45 ++++ packages/torph/src/lib/number-morph/index.ts | 239 ++++++++++++++++++ .../torph/src/lib/number-morph/segment.ts | 172 +++++++++++++ packages/torph/src/lib/number-morph/types.ts | 6 + packages/torph/src/lib/text-morph/index.ts | 40 +-- packages/torph/src/lib/text-morph/types.ts | 11 +- .../torph/src/lib/text-morph/utils/animate.ts | 84 +----- .../torph/src/lib/text-morph/utils/segment.ts | 6 +- packages/torph/src/lib/utils/animate.ts | 172 +++++++++++++ .../lib/{text-morph => }/utils/constants.ts | 0 .../src/lib/{text-morph => }/utils/dom.ts | 2 +- .../src/lib/{text-morph => }/utils/flip.ts | 0 .../{text-morph => }/utils/reduced-motion.ts | 0 .../src/lib/{text-morph => }/utils/spring.ts | 11 + .../src/lib/{text-morph => }/utils/styles.ts | 0 packages/torph/src/lib/utils/types.ts | 25 ++ packages/torph/src/react/NumberMorph.tsx | 76 ++++++ packages/torph/src/react/index.ts | 3 + 20 files changed, 892 insertions(+), 125 deletions(-) create mode 100644 packages/torph/src/lib/number-morph/animate.ts create mode 100644 packages/torph/src/lib/number-morph/controller.ts create mode 100644 packages/torph/src/lib/number-morph/index.ts create mode 100644 packages/torph/src/lib/number-morph/segment.ts create mode 100644 packages/torph/src/lib/number-morph/types.ts create mode 100644 packages/torph/src/lib/utils/animate.ts rename packages/torph/src/lib/{text-morph => }/utils/constants.ts (100%) rename packages/torph/src/lib/{text-morph => }/utils/dom.ts (98%) rename packages/torph/src/lib/{text-morph => }/utils/flip.ts (100%) rename packages/torph/src/lib/{text-morph => }/utils/reduced-motion.ts (100%) rename packages/torph/src/lib/{text-morph => }/utils/spring.ts (88%) rename packages/torph/src/lib/{text-morph => }/utils/styles.ts (100%) create mode 100644 packages/torph/src/lib/utils/types.ts create mode 100644 packages/torph/src/react/NumberMorph.tsx diff --git a/packages/torph/src/index.ts b/packages/torph/src/index.ts index 9980bca..a365279 100644 --- a/packages/torph/src/index.ts +++ b/packages/torph/src/index.ts @@ -1,3 +1,6 @@ export { DEFAULT_AS, DEFAULT_TEXT_MORPH_OPTIONS, MorphController, TextMorph } from "./lib/text-morph"; export type { TextMorphOptions } from "./lib/text-morph/types"; -export type { SpringParams } from "./lib/text-morph/utils/spring"; +export type { SpringParams } from "./lib/utils/spring"; + +export { DEFAULT_NUMBER_MORPH_OPTIONS, NumberMorph } from "./lib/number-morph"; +export type { NumberMorphOptions } from "./lib/number-morph/types"; diff --git a/packages/torph/src/lib/number-morph/animate.ts b/packages/torph/src/lib/number-morph/animate.ts new file mode 100644 index 0000000..4bed86b --- /dev/null +++ b/packages/torph/src/lib/number-morph/animate.ts @@ -0,0 +1,120 @@ +import { + parseTranslate, + cancelAnimations, + fadeDuration, +} from "../utils/animate"; + +export function animateNumberExit( + child: HTMLElement, + options: { + dx: number; + dy: number; + slideDistance: number; + duration: number; + ease: string; + }, +) { + const { dx, dy, slideDistance, duration, ease } = options; + + child.animate( + { + transform: `translate(${dx}px, ${dy + slideDistance}px)`, + offset: 1, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const fadeAnimation = child.animate( + { + opacity: 0, + offset: 1, + }, + { + duration: fadeDuration(duration, 0.25), + easing: "linear", + fill: "both", + }, + ); + + fadeAnimation.onfinish = () => child.remove(); +} + +export function animateNumberEnter( + child: HTMLElement, + options: { + deltaX: number; + deltaY: number; + slideDistance: number; + kind: "digit" | "symbol"; + duration: number; + ease: string; + }, +) { + const { deltaX, deltaY, slideDistance, kind, duration, ease } = options; + + const prev = cancelAnimations(child); + + const slideOffset = kind === "digit" ? -slideDistance : slideDistance; + const startX = deltaX + prev.tx; + const startY = deltaY + prev.ty + slideOffset; + + child.animate( + { + transform: `translate(${startX}px, ${startY}px)`, + offset: 0, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const startOpacity = prev.opacity >= 1 ? 0 : prev.opacity; + if (startOpacity < 1) { + child.animate( + [{ opacity: startOpacity }, { opacity: 1 }], + { + duration: fadeDuration(duration, 0.5), + easing: "linear", + fill: "both", + }, + ); + } +} + +export function animateNumberPersist( + child: HTMLElement, + options: { + deltaX: number; + deltaY: number; + duration: number; + ease: string; + }, +) { + const { deltaX, deltaY, duration, ease } = options; + + const { tx, ty } = parseTranslate(child); + child.getAnimations().forEach((a) => a.cancel()); + + const startX = deltaX + tx; + const startY = deltaY + ty; + + if (startX === 0 && startY === 0) return; + + child.animate( + { + transform: `translate(${startX}px, ${startY}px)`, + offset: 0, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); +} diff --git a/packages/torph/src/lib/number-morph/controller.ts b/packages/torph/src/lib/number-morph/controller.ts new file mode 100644 index 0000000..10e2572 --- /dev/null +++ b/packages/torph/src/lib/number-morph/controller.ts @@ -0,0 +1,45 @@ +import { NumberMorph } from "./index"; +import type { NumberMorphOptions } from "./types"; + +export class NumberMorphController { + private instance: NumberMorph | null = null; + private lastValue: number | string = ""; + private lastCursorIndex?: number; + private configKey = ""; + + attach(element: HTMLElement, options: Omit) { + this.instance?.destroy(); + this.instance = new NumberMorph({ element, ...options }); + this.configKey = NumberMorphController.serializeConfig(options); + + if (this.lastValue !== "") { + this.instance.update(this.lastValue, this.lastCursorIndex); + } + } + + update(value: number | string, cursorIndex?: number) { + this.lastValue = value; + this.lastCursorIndex = cursorIndex; + this.instance?.update(value, cursorIndex); + } + + needsRecreate(options: Omit): boolean { + return NumberMorphController.serializeConfig(options) !== this.configKey; + } + + destroy() { + this.instance?.destroy(); + this.instance = null; + } + + static serializeConfig(options: Omit): string { + return JSON.stringify({ + ease: options.ease, + duration: options.duration, + locale: options.locale, + decimals: options.decimals, + disabled: options.disabled, + respectReducedMotion: options.respectReducedMotion, + }); + } +} diff --git a/packages/torph/src/lib/number-morph/index.ts b/packages/torph/src/lib/number-morph/index.ts new file mode 100644 index 0000000..26bc2b8 --- /dev/null +++ b/packages/torph/src/lib/number-morph/index.ts @@ -0,0 +1,239 @@ +import type { NumberMorphOptions } from "./types"; +import { type NumberSegment, segmentNumber } from "./segment"; +import { + animateNumberExit, + animateNumberEnter, + animateNumberPersist, +} from "./animate"; +import { resolveEase } from "../utils/spring"; +import { BASE_DEFAULTS } from "../utils/types"; +import { + type Measures, + measure, + computeDelta, + findNearestAnchor, + resolveExitingAnchors, +} from "../utils/flip"; +import { transitionContainerSize } from "../utils/animate"; +import { detachFromFlow, reconcileChildren } from "../utils/dom"; +import { addStyles, removeStyles } from "../utils/styles"; +import { + ATTR_ROOT, + ATTR_ID, + ATTR_EXITING, +} from "../utils/constants"; +import { + type ReducedMotionState, + createReducedMotionListener, +} from "../utils/reduced-motion"; + +export type { NumberMorphOptions } from "./types"; +export type { NumberSegment } from "./segment"; +export { NumberMorphController } from "./controller"; + +export const DEFAULT_NUMBER_MORPH_OPTIONS = { + ...BASE_DEFAULTS, +} as const satisfies Omit; + +export class NumberMorph { + private element: HTMLElement; + private duration: number; + private ease: string; + private locale: string; + private decimals?: number; + private disabled: boolean; + private onAnimationStart?: () => void; + private onAnimationComplete?: () => void; + + private currentValue = ""; + private prevMeasures: Measures = {}; + private currentMeasures: Measures = {}; + private currentSegments: NumberSegment[] = []; + private isInitialRender = true; + private reducedMotion: ReducedMotionState | null = null; + + constructor(options: NumberMorphOptions) { + const opts = { ...DEFAULT_NUMBER_MORPH_OPTIONS, ...options }; + const { ease, duration } = resolveEase(opts.ease, opts.duration!); + + this.element = opts.element; + this.duration = duration; + this.ease = ease; + this.locale = opts.locale!; + this.decimals = opts.decimals; + this.disabled = opts.disabled!; + this.onAnimationStart = opts.onAnimationStart; + this.onAnimationComplete = opts.onAnimationComplete; + + if (opts.respectReducedMotion) { + this.reducedMotion = createReducedMotionListener(); + } + + if (!this.isDisabled()) { + this.element.setAttribute(ATTR_ROOT, ""); + this.element.style.transitionDuration = `${this.duration}ms`; + this.element.style.transitionTimingFunction = this.ease; + this.element.style.overflow = "hidden"; + addStyles(); + } + } + + destroy() { + this.reducedMotion?.destroy(); + this.element.getAnimations().forEach((anim) => anim.cancel()); + this.element.removeAttribute(ATTR_ROOT); + removeStyles(); + } + + private isDisabled(): boolean { + return Boolean( + this.disabled || this.reducedMotion?.prefersReducedMotion, + ); + } + + update(value: number | string, cursorIndex?: number) { + const formatted = + typeof value === "number" + ? value.toLocaleString(this.locale, { + minimumFractionDigits: this.decimals, + maximumFractionDigits: this.decimals, + }) + : value; + + if (formatted === this.currentValue) return; + this.currentValue = formatted; + + if (this.isDisabled()) { + this.element.textContent = formatted; + return; + } + + if (!this.isInitialRender && this.onAnimationStart) { + this.onAnimationStart(); + } + + const segments = segmentNumber(formatted, this.currentSegments, cursorIndex); + this.animate(segments); + } + + private animate(segments: NumberSegment[]) { + const element = this.element; + const oldWidth = element.offsetWidth; + const oldHeight = element.offsetHeight; + const slideDistance = element.offsetHeight || 20; + + this.prevMeasures = measure(element); + const oldChildren = Array.from(element.children) as HTMLElement[]; + const newIds = new Set(segments.map((s) => s.id)); + + const exiting = oldChildren.filter( + (child) => + !newIds.has(child.getAttribute(ATTR_ID) as string) && + !child.hasAttribute(ATTR_EXITING), + ); + const exitingSet = new Set(exiting); + const oldIds = oldChildren.map( + (c) => c.getAttribute(ATTR_ID) as string, + ); + + const exitingAnchorId = resolveExitingAnchors( + oldChildren, + exitingSet, + oldIds, + newIds, + ); + + detachFromFlow(exiting); + reconcileChildren(element, oldChildren, newIds, segments); + + this.currentMeasures = measure(element); + this.currentSegments = segments; + + exiting.forEach((child) => { + if (this.isInitialRender) { + child.remove(); + return; + } + + const anchorId = exitingAnchorId.get(child); + const { dx, dy } = anchorId + ? computeDelta(this.currentMeasures, this.prevMeasures, anchorId) + : { dx: 0, dy: 0 }; + + animateNumberExit(child, { + dx, + dy, + slideDistance, + duration: this.duration, + ease: this.ease, + }); + }); + + if (this.isInitialRender) { + this.isInitialRender = false; + element.style.width = "auto"; + element.style.height = "auto"; + return; + } + + this.animateChildren(segments, slideDistance); + + transitionContainerSize( + element, + oldWidth, + oldHeight, + this.duration, + this.onAnimationComplete, + ); + } + + private animateChildren(segments: NumberSegment[], slideDistance: number) { + const segmentIds = segments.map((s) => s.id); + const persistentIds = new Set( + segmentIds.filter((id) => this.prevMeasures[id]), + ); + const kindMap = new Map(segments.map((s) => [s.id, s.kind])); + + const children = Array.from(this.element.children) as HTMLElement[]; + children.forEach((child, index) => { + if (child.hasAttribute(ATTR_EXITING)) return; + + const key = child.getAttribute(ATTR_ID) || `child-${index}`; + const isNew = !this.prevMeasures[key]; + + if (isNew) { + const anchorKey = findNearestAnchor( + segments.findIndex((s) => s.id === key), + segmentIds, + persistentIds, + ); + + const { dx: deltaX, dy: deltaY } = anchorKey + ? computeDelta(this.prevMeasures, this.currentMeasures, anchorKey) + : { dx: 0, dy: 0 }; + + animateNumberEnter(child, { + deltaX, + deltaY, + slideDistance, + kind: kindMap.get(key) ?? "digit", + duration: this.duration, + ease: this.ease, + }); + } else { + const { dx: deltaX, dy: deltaY } = computeDelta( + this.prevMeasures, + this.currentMeasures, + key, + ); + + animateNumberPersist(child, { + deltaX, + deltaY, + duration: this.duration, + ease: this.ease, + }); + } + }); + } +} diff --git a/packages/torph/src/lib/number-morph/segment.ts b/packages/torph/src/lib/number-morph/segment.ts new file mode 100644 index 0000000..0b4d12b --- /dev/null +++ b/packages/torph/src/lib/number-morph/segment.ts @@ -0,0 +1,172 @@ +export type NumberSegment = { + id: string; + string: string; + kind: "digit" | "symbol"; +}; + +let nextNewId = 0; + +function classifyKind(char: string): NumberSegment["kind"] { + return /[0-9]/.test(char) ? "digit" : "symbol"; +} + +/** + * Segments a string into per-character NumberSegments. + * + * When `cursorIndex` is provided, uses position-based matching: + * characters before the edit keep their old IDs by position, + * characters after the edit keep theirs offset by the length change. + * + * When `cursorIndex` is not provided, falls back to greedy forward + * matching (works well for distinct characters). + */ +export function segmentNumber( + value: string, + prevSegments?: NumberSegment[], + cursorIndex?: number, +): NumberSegment[] { + const chars = value.split(""); + + if (!prevSegments || prevSegments.length === 0) { + return simpleSegment(chars); + } + + const oldChars = prevSegments.map((s) => + s.string === "\u00A0" ? " " : s.string, + ); + + const matches = + cursorIndex != null + ? cursorMatch(oldChars, chars, cursorIndex) + : greedyMatch(oldChars, chars); + + const usedIds = new Set(); + for (const [, oldIdx] of matches) { + usedIds.add(prevSegments[oldIdx]!.id); + } + + const result: NumberSegment[] = []; + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]!; + const kind = classifyKind(char); + const displayChar = char === " " ? "\u00A0" : char; + + if (matches.has(i)) { + const oldIdx = matches.get(i)!; + result.push({ + id: prevSegments[oldIdx]!.id, + string: displayChar, + kind, + }); + } else { + let id = `${char}_n${nextNewId++}`; + while (usedIds.has(id)) { + id = `${char}_n${nextNewId++}`; + } + usedIds.add(id); + result.push({ id, string: displayChar, kind }); + } + } + + return result; +} + +/** Occurrence-based segmentation for initial render. */ +function simpleSegment(chars: string[]): NumberSegment[] { + const counts = new Map(); + + return chars.map((char) => { + const kind = classifyKind(char); + const count = counts.get(char) ?? 0; + counts.set(char, count + 1); + + if (char === " ") { + return { + id: count > 0 ? `space_${count}` : "space", + string: "\u00A0", + kind, + }; + } + + return { + id: count > 0 ? `${char}_${count}` : char, + string: char, + kind, + }; + }); +} + +/** + * Position-based matching using cursor position. + * The cursor in the NEW string tells us where the edit happened: + * - Insertion: chars were added just before cursor. Prefix [0, cursor-inserted) + * maps 1:1, suffix [cursor, end) maps to old [cursor-inserted, end). + * - Deletion: chars were removed. Prefix [0, cursor) maps 1:1, + * suffix [cursor, end) maps to old [cursor+deleted, end). + */ +function cursorMatch( + oldChars: string[], + newChars: string[], + cursor: number, +): Map { + const matches = new Map(); + const lenDiff = newChars.length - oldChars.length; + + if (lenDiff > 0) { + const editStart = cursor - lenDiff; + for (let i = 0; i < editStart && i < oldChars.length; i++) { + matches.set(i, i); + } + for (let i = cursor; i < newChars.length; i++) { + const oldIdx = i - lenDiff; + if (oldIdx >= 0 && oldIdx < oldChars.length) { + matches.set(i, oldIdx); + } + } + } else if (lenDiff < 0) { + for (let i = 0; i < cursor && i < newChars.length; i++) { + matches.set(i, i); + } + for (let i = cursor; i < newChars.length; i++) { + const oldIdx = i - lenDiff; + if (oldIdx >= 0 && oldIdx < oldChars.length) { + matches.set(i, oldIdx); + } + } + } else { + for (let i = 0; i < newChars.length; i++) { + if (newChars[i] === oldChars[i]) { + matches.set(i, i); + } + } + } + + return matches; +} + +/** + * Greedy forward matching: matches each old character to the earliest + * available position in the new string. Fallback when no cursor info. + * + * Returns a Map of newIndex → oldIndex for matched characters. + */ +function greedyMatch( + oldChars: string[], + newChars: string[], +): Map { + const matches = new Map(); + let newStart = 0; + + for (let i = 0; i < oldChars.length; i++) { + for (let j = newStart; j < newChars.length; j++) { + if (oldChars[i] === newChars[j]) { + matches.set(j, i); + newStart = j + 1; + break; + } + } + } + + return matches; +} diff --git a/packages/torph/src/lib/number-morph/types.ts b/packages/torph/src/lib/number-morph/types.ts new file mode 100644 index 0000000..5a0c91b --- /dev/null +++ b/packages/torph/src/lib/number-morph/types.ts @@ -0,0 +1,6 @@ +import type { BaseMorphOptions } from "../utils/types"; + +export interface NumberMorphOptions extends BaseMorphOptions { + locale?: string; + decimals?: number; +} diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index 51a6575..68b69c7 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -1,44 +1,40 @@ import type { TextMorphOptions } from "./types"; -import { spring as resolveSpring } from "./utils/spring"; -import { type Segment, segmentText } from "./utils/segment"; +import { BASE_DEFAULTS, type Segment } from "../utils/types"; +import { resolveEase } from "../utils/spring"; +import { segmentText } from "./utils/segment"; import { type Measures, measure, computeDelta, findNearestAnchor, resolveExitingAnchors, -} from "./utils/flip"; +} from "../utils/flip"; import { - animateExit, - animateEnterOrPersist, transitionContainerSize, -} from "./utils/animate"; -import { detachFromFlow, reconcileChildren } from "./utils/dom"; -import { addStyles, removeStyles } from "./utils/styles"; +} from "../utils/animate"; +import { animateExit, animateEnterOrPersist } from "./utils/animate"; +import { detachFromFlow, reconcileChildren } from "../utils/dom"; +import { addStyles, removeStyles } from "../utils/styles"; import { ATTR_ROOT, ATTR_DEBUG, ATTR_EXITING, ATTR_ID, -} from "./utils/constants"; +} from "../utils/constants"; import { type ReducedMotionState, createReducedMotionListener, -} from "./utils/reduced-motion"; +} from "../utils/reduced-motion"; export type { TextMorphOptions } from "./types"; -export type { SpringParams } from "./utils/spring"; +export type { SpringParams } from "../utils/spring"; export { MorphController } from "./controller"; export const DEFAULT_AS = "span"; export const DEFAULT_TEXT_MORPH_OPTIONS = { + ...BASE_DEFAULTS, debug: false, - locale: "en", - duration: 400, scale: true, - ease: "cubic-bezier(0.19, 1, 0.22, 1)", - disabled: false, - respectReducedMotion: true, } as const satisfies Omit; export class TextMorph { @@ -55,17 +51,7 @@ export class TextMorph { constructor(options: TextMorphOptions) { const { ease: rawEase, ...rest } = { ...DEFAULT_TEXT_MORPH_OPTIONS, ...options }; - let ease: string; - let duration: number; - - if (typeof rawEase === "object") { - const resolved = resolveSpring(rawEase); - ease = resolved.easing; - duration = resolved.duration; - } else { - ease = rawEase; - duration = rest.duration!; - } + const { ease, duration } = resolveEase(rawEase, rest.duration!); this.options = { ...rest, ease, duration }; diff --git a/packages/torph/src/lib/text-morph/types.ts b/packages/torph/src/lib/text-morph/types.ts index 14e2e65..40576a6 100644 --- a/packages/torph/src/lib/text-morph/types.ts +++ b/packages/torph/src/lib/text-morph/types.ts @@ -1,14 +1,7 @@ -import type { SpringParams } from "./utils/spring"; +import type { BaseMorphOptions } from "../utils/types"; -export interface TextMorphOptions { +export interface TextMorphOptions extends BaseMorphOptions { debug?: boolean; - element: HTMLElement; locale?: Intl.LocalesArgument; scale?: boolean; - duration?: number; // in ms - ease?: string | SpringParams; - disabled?: boolean; - respectReducedMotion?: boolean; - onAnimationStart?: () => void; - onAnimationComplete?: () => void; } diff --git a/packages/torph/src/lib/text-morph/utils/animate.ts b/packages/torph/src/lib/text-morph/utils/animate.ts index 16fb0b6..3e0cb0f 100644 --- a/packages/torph/src/lib/text-morph/utils/animate.ts +++ b/packages/torph/src/lib/text-morph/utils/animate.ts @@ -1,31 +1,4 @@ -const MAX_FADE_DURATION = 150; - -function fadeDuration(duration: number, fraction: number): number { - return Math.min(duration * fraction, MAX_FADE_DURATION); -} - -export function parseTranslate(element: HTMLElement): { - tx: number; - ty: number; -} { - const transform = getComputedStyle(element).transform; - if (!transform || transform === "none") return { tx: 0, ty: 0 }; - const match = transform.match(/matrix\(([^)]+)\)/); - if (!match) return { tx: 0, ty: 0 }; - const v = match[1]!.split(",").map(Number); - return { tx: v[4] || 0, ty: v[5] || 0 }; -} - -function cancelAnimations(element: HTMLElement): { - tx: number; - ty: number; - opacity: number; -} { - const { tx, ty } = parseTranslate(element); - const opacity = Number(getComputedStyle(element).opacity) || 1; - element.getAnimations().forEach((a) => a.cancel()); - return { tx, ty, opacity }; -} +import { cancelAnimations, fadeDuration } from "../../utils/animate"; export function animateExit( child: HTMLElement, @@ -109,58 +82,3 @@ export function animateEnterOrPersist( ); } } - -let pendingCleanup: (() => void) | null = null; - -export function transitionContainerSize( - element: HTMLElement, - oldWidth: number, - oldHeight: number, - duration: number, - onComplete?: () => void, -) { - // Cancel any pending cleanup from a previous transition - if (pendingCleanup) { - pendingCleanup(); - pendingCleanup = null; - } - - if (oldWidth === 0 || oldHeight === 0) return; - - element.style.width = "auto"; - element.style.height = "auto"; - void element.offsetWidth; - - const newWidth = element.offsetWidth; - const newHeight = element.offsetHeight; - - element.style.width = `${oldWidth}px`; - element.style.height = `${oldHeight}px`; - void element.offsetWidth; - - element.style.width = `${newWidth}px`; - element.style.height = `${newHeight}px`; - - function cleanup() { - element.removeEventListener("transitionend", onEnd); - clearTimeout(fallbackTimer); - pendingCleanup = null; - element.style.width = "auto"; - element.style.height = "auto"; - onComplete?.(); - } - - function onEnd(e: TransitionEvent) { - if (e.target !== element) return; - if (e.propertyName !== "width" && e.propertyName !== "height") return; - cleanup(); - } - - element.addEventListener("transitionend", onEnd); - const fallbackTimer = setTimeout(cleanup, duration + 50); - pendingCleanup = () => { - element.removeEventListener("transitionend", onEnd); - clearTimeout(fallbackTimer); - pendingCleanup = null; - }; -} diff --git a/packages/torph/src/lib/text-morph/utils/segment.ts b/packages/torph/src/lib/text-morph/utils/segment.ts index e7e7791..18336dd 100644 --- a/packages/torph/src/lib/text-morph/utils/segment.ts +++ b/packages/torph/src/lib/text-morph/utils/segment.ts @@ -1,7 +1,5 @@ -export type Segment = { - id: string; - string: string; -}; +export type { Segment } from "../../utils/types"; +import type { Segment } from "../../utils/types"; export function segmentText( value: string, diff --git a/packages/torph/src/lib/utils/animate.ts b/packages/torph/src/lib/utils/animate.ts new file mode 100644 index 0000000..5198cab --- /dev/null +++ b/packages/torph/src/lib/utils/animate.ts @@ -0,0 +1,172 @@ +const MAX_FADE_DURATION = 150; + +export function fadeDuration(duration: number, fraction: number): number { + return Math.min(duration * fraction, MAX_FADE_DURATION); +} + +export function parseTranslate(element: HTMLElement): { + tx: number; + ty: number; +} { + const transform = getComputedStyle(element).transform; + if (!transform || transform === "none") return { tx: 0, ty: 0 }; + const match = transform.match(/matrix\(([^)]+)\)/); + if (!match) return { tx: 0, ty: 0 }; + const v = match[1]!.split(",").map(Number); + return { tx: v[4] || 0, ty: v[5] || 0 }; +} + +export function cancelAnimations(element: HTMLElement): { + tx: number; + ty: number; + opacity: number; +} { + const { tx, ty } = parseTranslate(element); + const opacity = Number(getComputedStyle(element).opacity) || 1; + element.getAnimations().forEach((a) => a.cancel()); + return { tx, ty, opacity }; +} + +export function animateExit( + child: HTMLElement, + options: { + dx: number; + dy: number; + duration: number; + ease: string; + scale: boolean; + }, +) { + const { dx, dy, duration, ease, scale } = options; + + child.animate( + { + transform: scale + ? `translate(${dx}px, ${dy}px) scale(0.95)` + : `translate(${dx}px, ${dy}px)`, + offset: 1, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const fadeAnimation = child.animate( + { + opacity: 0, + offset: 1, + }, + { + duration: fadeDuration(duration, 0.25), + easing: "linear", + fill: "both", + }, + ); + + fadeAnimation.onfinish = () => child.remove(); +} + +export function animateEnterOrPersist( + child: HTMLElement, + options: { + deltaX: number; + deltaY: number; + isNew: boolean; + duration: number; + ease: string; + }, +) { + const { deltaX, deltaY, isNew, duration, ease } = options; + + const prev = cancelAnimations(child); + + const startX = deltaX + prev.tx; + const startY = deltaY + prev.ty; + + child.animate( + { + transform: `translate(${startX}px, ${startY}px) scale(${isNew ? 0.95 : 1})`, + offset: 0, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const startOpacity = isNew && prev.opacity >= 1 ? 0 : prev.opacity; + if (startOpacity < 1) { + child.animate( + [{ opacity: startOpacity }, { opacity: 1 }], + { + duration: fadeDuration(duration, isNew ? 0.5 : 0.25), + easing: "linear", + fill: "both", + }, + ); + } +} + +let pendingCleanup: (() => void) | null = null; + +export function transitionContainerSize( + element: HTMLElement, + oldWidth: number, + oldHeight: number, + duration: number, + onComplete?: () => void, +) { + // Cancel any pending cleanup from a previous transition + if (pendingCleanup) { + pendingCleanup(); + pendingCleanup = null; + } + + if (oldWidth === 0 || oldHeight === 0) { + element.style.width = "auto"; + element.style.height = "auto"; + return; + } + + element.style.width = "auto"; + element.style.height = "auto"; + void element.offsetWidth; + + const newWidth = element.offsetWidth; + const newHeight = element.offsetHeight; + + element.style.width = `${oldWidth}px`; + element.style.height = `${oldHeight}px`; + void element.offsetWidth; + + element.style.width = `${newWidth}px`; + element.style.height = `${newHeight}px`; + + function cleanup() { + element.removeEventListener("transitionend", onEnd); + clearTimeout(fallbackTimer); + pendingCleanup = null; + element.style.width = "auto"; + element.style.height = "auto"; + onComplete?.(); + } + + function onEnd(e: TransitionEvent) { + if (e.target !== element) return; + if (e.propertyName !== "width" && e.propertyName !== "height") return; + cleanup(); + } + + element.addEventListener("transitionend", onEnd); + const fallbackTimer = setTimeout(cleanup, duration + 50); + pendingCleanup = () => { + element.removeEventListener("transitionend", onEnd); + clearTimeout(fallbackTimer); + element.style.width = "auto"; + element.style.height = "auto"; + pendingCleanup = null; + }; +} diff --git a/packages/torph/src/lib/text-morph/utils/constants.ts b/packages/torph/src/lib/utils/constants.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/constants.ts rename to packages/torph/src/lib/utils/constants.ts diff --git a/packages/torph/src/lib/text-morph/utils/dom.ts b/packages/torph/src/lib/utils/dom.ts similarity index 98% rename from packages/torph/src/lib/text-morph/utils/dom.ts rename to packages/torph/src/lib/utils/dom.ts index ede02c2..e307e61 100644 --- a/packages/torph/src/lib/text-morph/utils/dom.ts +++ b/packages/torph/src/lib/utils/dom.ts @@ -1,4 +1,4 @@ -import type { Segment } from "./segment"; +import type { Segment } from "./types"; import { ATTR_EXITING, ATTR_ID, ATTR_ITEM } from "./constants"; import { parseTranslate } from "./animate"; diff --git a/packages/torph/src/lib/text-morph/utils/flip.ts b/packages/torph/src/lib/utils/flip.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/flip.ts rename to packages/torph/src/lib/utils/flip.ts diff --git a/packages/torph/src/lib/text-morph/utils/reduced-motion.ts b/packages/torph/src/lib/utils/reduced-motion.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/reduced-motion.ts rename to packages/torph/src/lib/utils/reduced-motion.ts diff --git a/packages/torph/src/lib/text-morph/utils/spring.ts b/packages/torph/src/lib/utils/spring.ts similarity index 88% rename from packages/torph/src/lib/text-morph/utils/spring.ts rename to packages/torph/src/lib/utils/spring.ts index 3dac06e..37e7636 100644 --- a/packages/torph/src/lib/text-morph/utils/spring.ts +++ b/packages/torph/src/lib/utils/spring.ts @@ -57,6 +57,17 @@ function computeDuration( return Math.ceil(maxDuration * 1000); } +export function resolveEase( + ease: string | SpringParams, + fallbackDuration: number, +): { ease: string; duration: number } { + if (typeof ease === "object") { + const resolved = spring(ease); + return { ease: resolved.easing, duration: resolved.duration }; + } + return { ease, duration: fallbackDuration }; +} + const cache = new Map(); export function spring(params?: SpringParams): SpringResult { diff --git a/packages/torph/src/lib/text-morph/utils/styles.ts b/packages/torph/src/lib/utils/styles.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/styles.ts rename to packages/torph/src/lib/utils/styles.ts diff --git a/packages/torph/src/lib/utils/types.ts b/packages/torph/src/lib/utils/types.ts new file mode 100644 index 0000000..59a619d --- /dev/null +++ b/packages/torph/src/lib/utils/types.ts @@ -0,0 +1,25 @@ +import type { SpringParams } from "./spring"; + +export type Segment = { + id: string; + string: string; +}; + +export interface BaseMorphOptions { + element: HTMLElement; + duration?: number; + ease?: string | SpringParams; + locale?: Intl.LocalesArgument; + disabled?: boolean; + respectReducedMotion?: boolean; + onAnimationStart?: () => void; + onAnimationComplete?: () => void; +} + +export const BASE_DEFAULTS = { + locale: "en", + duration: 400, + ease: "cubic-bezier(0.19, 1, 0.22, 1)", + disabled: false, + respectReducedMotion: true, +} as const; diff --git a/packages/torph/src/react/NumberMorph.tsx b/packages/torph/src/react/NumberMorph.tsx new file mode 100644 index 0000000..aec0207 --- /dev/null +++ b/packages/torph/src/react/NumberMorph.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React from "react"; +import { NumberMorphController } from "../lib/number-morph/controller"; +import type { NumberMorphOptions } from "../lib/number-morph/types"; + +export type NumberMorphProps = Omit & { + children: number | string; + cursorIndex?: number; + className?: string; + style?: React.CSSProperties; + as?: React.ElementType; +}; + +function childrenToValue(node: React.ReactNode): number | string { + if (typeof node === "string") return node; + if (typeof node === "number") return node; + if (Array.isArray(node)) return node.map(childrenToValue).join(""); + return ""; +} + +export const NumberMorph = ({ + children, + cursorIndex, + className, + style, + as: Component = "span", + ...props +}: NumberMorphProps) => { + const { ref, update } = useNumberMorph(props); + const value = childrenToValue(children); + const cursorRef = React.useRef(cursorIndex); + cursorRef.current = cursorIndex; + const initialHTML = React.useRef({ + __html: typeof value === "number" ? String(value) : value, + }); + + React.useEffect(() => { + update(value, cursorRef.current); + }, [value, update]); + + return ( + + ); +}; + +export function useNumberMorph(props: Omit) { + const ref = React.useRef(null); + const controllerRef = React.useRef(new NumberMorphController()); + + const configKey = NumberMorphController.serializeConfig(props); + + React.useEffect(() => { + if (ref.current) { + controllerRef.current.attach(ref.current, props); + } + + return () => { + controllerRef.current.destroy(); + }; + }, [configKey]); + + const update = React.useCallback( + (value: number | string, cursorIndex?: number) => { + controllerRef.current.update(value, cursorIndex); + }, + [], + ); + + return { ref, update }; +} diff --git a/packages/torph/src/react/index.ts b/packages/torph/src/react/index.ts index 7536e8c..315ee20 100644 --- a/packages/torph/src/react/index.ts +++ b/packages/torph/src/react/index.ts @@ -1,3 +1,6 @@ export { TextMorph, useTextMorph } from "./TextMorph"; export type { TextMorphProps } from "./TextMorph"; +export { NumberMorph, useNumberMorph } from "./NumberMorph"; +export type { NumberMorphProps } from "./NumberMorph"; + From 2312d1a75ebe12ad67f453837024de831d7da0bd Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 7 Mar 2026 09:45:21 +1100 Subject: [PATCH 2/2] example --- .../src/surfaces/homepage/examples/number.tsx | 92 +++++++++++++------ .../homepage/examples/styles.module.scss | 7 +- 2 files changed, 68 insertions(+), 31 deletions(-) diff --git a/site/src/surfaces/homepage/examples/number.tsx b/site/src/surfaces/homepage/examples/number.tsx index b9b80d2..5a1940a 100644 --- a/site/src/surfaces/homepage/examples/number.tsx +++ b/site/src/surfaces/homepage/examples/number.tsx @@ -1,46 +1,84 @@ import styles from "./styles.module.scss"; import React from "react"; -import { TextMorph } from "torph/react"; - -// Simulating typing: showing intermediate states as if someone is typing -const typingSequence = [ - { value: "$", delay: 0 }, // Start typing - { value: "$2", delay: 150 }, // Type 2 - { value: "$20", delay: 120 }, // Type 0 - { value: "$20", delay: 1800 }, // Pause to read - { value: "$", delay: 200 }, // Delete and start new - { value: "$4", delay: 150 }, // Type 4 - { value: "$45", delay: 120 }, // Type 5 - { value: "$45.", delay: 180 }, // Type decimal - { value: "$45.9", delay: 140 }, // Type 9 - { value: "$45.99", delay: 120 }, // Type 9 - { value: "$45.99", delay: 1800 }, // Pause to read - { value: "$", delay: 200 }, // Delete and start new - { value: "$1", delay: 150 }, // Type 1 - { value: "$12", delay: 120 }, // Type 2 - { value: "$12.", delay: 180 }, // Type decimal - { value: "$12.5", delay: 140 }, // Type 5 - { value: "$12.50", delay: 120 }, // Type 0 - { value: "$12.50", delay: 1700 }, // Final pause +import { NumberMorph } from "torph/react"; + +const sequence = [ + // Type $20 + { value: "$", cursor: 1, delay: 0 }, + { value: "$2", cursor: 2, delay: 150 }, + { value: "$20", cursor: 3, delay: 1200 }, + { value: "$20", cursor: 3, delay: 1800 }, + + // Move cursor before 2, then insert 1 → $120 + { value: "$20", cursor: 1, delay: 200 }, + { value: "$420", cursor: 2, delay: 400 }, + { value: "$4,020", cursor: 4, delay: 1800 }, + + // Move cursor between 1 and 2, then insert . → $1.20 + { value: "$420", cursor: 2, delay: 400 }, + { value: "$4.20", cursor: 3, delay: 400 }, + { value: "$4.20", cursor: 3, delay: 1800 }, + + { value: "$4.20", cursor: 5, delay: 1800 }, + { value: "$4.2", cursor: 4, delay: 200 }, + { value: "$4", cursor: 2, delay: 200 }, + { value: "$", cursor: 1, delay: 200 }, ]; export const ExampleNumber = () => { const [currentIndex, setCurrentIndex] = React.useState(0); React.useEffect(() => { - const currentStep = typingSequence[currentIndex]; + const step = sequence[currentIndex]; const timeout = setTimeout(() => { - setCurrentIndex((prevIndex) => (prevIndex + 1) % typingSequence.length); - }, currentStep.delay); + setCurrentIndex((prev) => (prev + 1) % sequence.length); + }, step.delay); return () => clearTimeout(timeout); }, [currentIndex]); + const step = sequence[currentIndex]; + const measureRef = React.useRef(null); + const containerRef = React.useRef(null); + const [cursorX, setCursorX] = React.useState(null); + + React.useLayoutEffect(() => { + if (measureRef.current && containerRef.current) { + const measureRect = measureRef.current.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + setCursorX(measureRect.right - containerRect.left); + } + }, [currentIndex]); + return (
- {typingSequence[currentIndex].value} - +
+ {step.value} + + {step.value.slice(0, step.cursor)} + + +
); }; diff --git a/site/src/surfaces/homepage/examples/styles.module.scss b/site/src/surfaces/homepage/examples/styles.module.scss index 699d9b4..3533a61 100644 --- a/site/src/surfaces/homepage/examples/styles.module.scss +++ b/site/src/surfaces/homepage/examples/styles.module.scss @@ -134,13 +134,12 @@ box-shadow: 0 0 0 1px var(--body-light); .cursor { - transform: translateY(1px); border-radius: 1rem; - width: 2px; + width: 1px; height: 0.95em; - background: rgba(255, 255, 255, 0.1); - margin-left: 0.25rem; + background: rgba(255, 255, 255, 0.3); animation: blink 1s step-start infinite; + transition: left 0.3s ease; @keyframes blink { 0% {