Files
hanzo.ai/components/motion.tsx
zeekay d6e029ab18 A badged heading centres its lines, light code reads at AA, and the Kai answer is never NaN
- ChromeText centres the stack when there is a badge, but the heading box
  is as wide as the stack once it wraps, so at 360px /playground's two-line
  heading started at the left, 76px off the axis its badge and lede share.
  It centres its text too.
- GitHub Light's orange token (#E36209) is 3.49:1 on its white, under AA
  since 11265a21 put every sample through shiki. A light token ink under
  4.5:1 is darkened, hue kept, until it clears it.
- useTween with `ms` 0 divided 0 by 0 when the frame's time was the effect's
  own, and a negative elapsed share eased to NaN: /kai's distribution drew
  `NaN` on some loads. The share is held to [0, 1] and 0 ms arrives at once.
2026-10-15 09:41:46 -07:00

180 lines
7.6 KiB
TypeScript

'use client'
/**
* motion — framer-motion, with one rule added: a scroll reveal may not hide content.
*
* `whileInView` is an ENHANCEMENT, and the site had it holding the content
* hostage. The pairing every page reached for was
*
* initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }}
*
* and `initial` is rendered — framer-motion writes it into the `style`
* attribute of the exported HTML. So the shipped page states that the section
* is transparent, and the ONLY thing that can take it back is an
* IntersectionObserver firing after hydration. Every way that observer can be
* late or absent — a viewport it never enters, a hydration error, a slow chunk,
* a browser that never runs the script — leaves real copy at `opacity: 0`
* forever. Measured on the live site at 1440x900: 4,380px of /about, 972px of
* /solutions and 2,229px of the homepage rendered as black.
*
* The tell was that reduced-motion readers saw MORE than everyone else: a
* rescue rule forced the start state visible under `prefers-reduced-motion` and
* with scripting off, so the two audiences nobody designs for were the two the
* page worked for. A reveal that needs a rescue rule to be readable is not a
* reveal, it is a hiding place, and with this grounding none is left.
*
* So the start state is grounded here, once, instead of at 815 call sites: the
* element ships opaque and in position, and what is left of `initial` is a
* nudge the entrance closes. The same grounding covers an `animate` entrance
* (`initial={{ opacity: 0 }}` then `animate={{ opacity: 1 }}`), which is how
* the marketing heroes open — otherwise the first paint is a blank headline.
* Nothing about a call site changes — the same props, the same durations, the
* same stagger — and there is no arrangement of them that can blank a section
* again.
*/
import * as React from 'react'
import { LazyMotion, m as framer } from 'framer-motion'
/**
* The animation features, fetched after the page: `motion.*` is framer-motion's
* `m`, which renders at once and animates once these arrive, so the 35 KB of
* animation engine is not part of any page's first load. Each element carries
* its own `LazyMotion`, so a page that animates nothing loads none of it.
* `domAnimation` is enough: nothing here animates layout or drags.
*/
const features = () => import('./motion-features').then((r) => r.default)
/**
* How far a reveal may start from where it belongs.
*
* A rise is the house entrance (`page-kit`'s `useRise` opens the same way), and
* this is the whole of it: 8px is motion when it animates and nothing at all
* when it does not, which is the point — the un-revealed page has to be a page
* somebody can read, not a page waiting for one. Horizontal offsets are dropped
* outright rather than clamped: an `x: -20` enter is wider than the viewport for
* the length of the animation, which is the 4px sideways scroll `overflow-x:
* clip` in `app/globals.css` exists to swallow.
*/
const NUDGE = 8
/** Reveal a section as it comes to the fold, not after. */
const EARLY = '0px 0px 96px 0px'
/**
* A start state that a reader can read.
*
* `opacity`, `x` and `scale` are what make content unreadable when the reveal
* does not run, so they never reach the DOM; `y` survives, clamped. Anything
* else the call site asked for is its own business — `width: 0` draws a bar
* growing, and an undrawn ornament is not a missing section.
*
* A variant NAME (`initial="hidden"`) cannot be inspected from here, so it
* becomes `false`: the element mounts in the state `whileInView` targets, which
* is the visible one, and its children inherit that through motion's context.
*/
function grounded(initial: unknown) {
if (!initial || typeof initial !== 'object' || Array.isArray(initial)) return false
const { opacity, x, scale, y, ...rest } = initial as Record<string, unknown>
const start =
typeof y === 'number' ? { ...rest, y: Math.max(-NUDGE, Math.min(NUDGE, y)) } : rest
return Object.keys(start).length ? start : false
}
const wrapped = new Map<string, React.ComponentType<Record<string, unknown>>>()
export const motion = new Proxy(framer, {
get(target, key) {
// Intrinsic elements only — `motion.div`, `motion.section`. `create` and the
// symbols are motion's own and pass straight through.
if (typeof key !== 'string' || !/^[a-z]/.test(key)) return Reflect.get(target, key)
let El = wrapped.get(key)
if (!El) {
const Base = Reflect.get(target, key) as React.ComponentType<Record<string, unknown>>
El = (props: Record<string, unknown>) => {
const enter =
Boolean(props.whileInView) ||
(Boolean(props.animate) && !!props.initial && typeof props.initial === 'object')
return (
<LazyMotion features={features}>
{enter ? (
<Base
{...props}
initial={grounded(props.initial)}
{...(props.whileInView
? { viewport: { once: true, margin: EARLY, ...(props.viewport as object) } }
: null)}
/>
) : (
<Base {...props} />
)}
</LazyMotion>
)
}
El.displayName = `motion.${key}`
wrapped.set(key, El)
}
return El
},
}) as typeof framer
/**
* Whether the reader asked for less motion. False on the server and on the first
* client render, so the export and hydration agree; it settles a frame later.
*/
export function useReduced(): boolean {
const [reduced, setReduced] = React.useState(false)
React.useEffect(() => {
const q = window.matchMedia('(prefers-reduced-motion: reduce)')
const set = () => setReduced(q.matches)
set()
q.addEventListener('change', set)
return () => q.removeEventListener('change', set)
}, [])
return reduced
}
/** The house ease for a value moving to where it belongs: fast out, soft in. */
const settle = (t: number) => 1 - Math.pow(1 - t, 3)
/**
* Values that travel to their targets instead of jumping: a number, or a
* distribution. Each frame is a straight blend of where the values were and
* where they are going, so two distributions that each sum to one sum to one on
* every frame between them — the bars deform, they never reset. With reduced
* motion the values arrive at once. The first render returns `from` (or the
* targets), so the server and the client draw the same thing.
*/
export function useTween(to: readonly number[], { ms = 700, from }: { ms?: number; from?: readonly number[] } = {}): number[] {
const reduced = useReduced()
const [now, setNow] = React.useState<number[]>(() => [...(from ?? to)])
const last = React.useRef<number[]>(now)
const key = to.join(',')
React.useEffect(() => {
const start = last.current
const end = [...to]
if (reduced || start.length !== end.length) {
last.current = end
setNow(end)
return
}
let raf = 0
const t0 = performance.now()
// A frame's time can be the moment the effect ran or before it, so the
// elapsed share is held to [0, 1], and `ms` 0 arrives at once: 0 / 0 is NaN,
// and a bar drawn at NaN stays there.
const step = (t: number) => {
const k = ms > 0 ? settle(Math.min(1, Math.max(0, (t - t0) / ms))) : 1
const v = start.map((a, i) => a + (end[i] - a) * k)
last.current = v
setNow(v)
if (k < 1) raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
// `key` stands for `to`'s values: a new array of the same numbers is not a move.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ms, reduced])
return now
}