Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.animation-controls {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 0 24px 22px;
}

.animation-controls button {
border: 1px solid var(--animation-border);
border-radius: 4px;
background: transparent;
color: var(--animation-ink);
padding: 7px 13px;
font-size: 12px;
min-width: 76px;
}

.animation-controls .animation-play {
background: light-dark(#eeece4, #292923);
}

.animation-controls button:hover:not(:disabled) {
border-color: var(--animation-muted);
}

.animation-controls button:disabled {
opacity: 0.3;
cursor: default;
}

@media (max-width: 600px) {
.animation-controls {
padding: 0 16px 20px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import "./AnimationControls.css"

export function AnimationControls({
playing,
onRestart,
onToggle,
onNext,
}: {
playing: boolean
onRestart: () => void
onToggle: () => void
onNext?: () => void
}) {
return (
<div className="animation-controls">
<button aria-label="Restart animation" onClick={onRestart}>
↻ Restart
</button>
<button className="animation-play" onClick={onToggle}>
{playing ? "Ⅱ Pause" : "▷ Play"}
</button>
{onNext && <button onClick={onNext}>Next step →</button>}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
.animation-demo {
container-type: inline-size;
--animation-bg: light-dark(#faf9f6, #171714);
--animation-panel: light-dark(#ffffff, #1f1f1b);
--animation-border: light-dark(#d9d6cc, #383830);
--animation-muted: light-dark(#686459, #a09e91);
--animation-ink: light-dark(#292820, #eeede5);
--animation-accent: light-dark(#a85b20, #eaa36d);
margin: 0;
color: var(--animation-ink);
background: var(--animation-bg);
border: 1px solid var(--animation-border);
border-radius: 8px;
overflow: hidden;
font:
14px/1.5 system-ui,
sans-serif;
color-scheme: light;
transition:
border-color 250ms ease,
box-shadow 250ms ease;
}

.animation-demo[data-running="true"] {
border-color: color-mix(
in srgb,
var(--animation-accent) 60%,
var(--animation-border)
);
box-shadow: 0 0 12px
color-mix(in srgb, var(--animation-accent) 18%, transparent);
}

.dark .animation-demo {
color-scheme: dark;
}

.animation-demo * {
box-sizing: border-box;
}

.animation-demo button {
font: inherit;
cursor: pointer;
}

.animation-demo button:focus-visible {
outline: 2px solid var(--animation-accent);
outline-offset: -3px;
}

@media (prefers-reduced-motion: reduce) {
.animation-demo,
.animation-demo * {
transition: none !important;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ComponentProps } from "react"
import "./AnimationFrame.css"

export function AnimationFrame({
className = "",
...props
}: ComponentProps<"figure">) {
return <figure {...props} className={`animation-demo ${className}`} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.animation-packet rect {
fill: light-dark(#fff0df, #30271d);
stroke: var(--animation-accent);
}

.animation-demo svg .animation-packet text {
fill: light-dark(#914c18, #f3c69e);
font-size: 12px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { motion, useTransform, type MotionValue } from "motion/react"
import "./AnimationPacket.css"

/** A labeled message following the cubic curve between two diagram nodes. */
export function AnimationPacket({
clock,
start,
duration,
fromX,
fromY,
toX,
toY,
label,
controlX,
width = 48,
className = "",
}: {
clock: MotionValue<number>
start: number
duration: number
fromX: number
fromY: number
toX: number
toY: number
label: string
controlX?: number | undefined
width?: number
className?: string
}) {
const progress = useTransform(clock, (time) =>
Math.max(0, Math.min(1, (time - start) / duration)),
)
const x = useTransform(progress, (t) => {
const middle = controlX ?? (fromX + toX) / 2
return (1 - t) ** 3 * fromX + 3 * (1 - t) * t * middle + t ** 3 * toX
})
const y = useTransform(
progress,
(t) => fromY + (toY - fromY) * (3 * t * t - 2 * t ** 3),
)
const opacity = useTransform(clock, (time) => {
const t = (time - start) / duration
return Math.max(0, Math.min(1, t * 10, (1 - t) * 10))
})
return (
<motion.g
className={`animation-packet ${className}`}
style={{ x, y, opacity }}
aria-hidden="true"
>
<rect x={-width / 2} y="-12" width={width} height="24" rx="3" />
<text y="4" textAnchor="middle">
{label}
</text>
</motion.g>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react"
import { useMotionValue } from "motion/react"

/** Shared playback for deterministic diagrams. Time is measured in seconds. */
export function useAnimationPlayback(duration: number) {
const figure = useRef<HTMLElement>(null)
const clock = useMotionValue(0)
const [time, setTime] = useState(0)
const [playing, setPlaying] = useState(false)
const [visible, setVisible] = useState(false)
const [compact, setCompact] = useState(false)
const [reducedMotion, setReducedMotion] = useState(false)

useEffect(() => {
const element = figure.current
if (!element) return
const observer = new ResizeObserver(([entry]) => {
if (entry) setCompact(entry.contentRect.width <= 640)
})
observer.observe(element)
return () => observer.disconnect()
}, [])

useEffect(() => {
const preference = window.matchMedia("(prefers-reduced-motion: reduce)")
const update = () => {
setReducedMotion(preference.matches)
setPlaying(!preference.matches)
}
update()
preference.addEventListener("change", update)
return () => preference.removeEventListener("change", update)
}, [])

useEffect(() => {
let inView = false
const update = () => setVisible(inView && !document.hidden)
const observer = new IntersectionObserver(([entry]) => {
inView = entry?.isIntersecting ?? false
update()
})
if (figure.current) observer.observe(figure.current)
document.addEventListener("visibilitychange", update)
return () => {
observer.disconnect()
document.removeEventListener("visibilitychange", update)
}
}, [])

useEffect(() => {
if (!playing || !visible) return
let previous: number | undefined
let request: number
const tick = (now: number) => {
if (previous !== undefined) {
const next = (clock.get() + (now - previous) / 1000) % duration
clock.set(next)
// Continuous motion uses the clock; text only needs ten updates/second.
setTime((old) =>
Math.floor(old * 10) === Math.floor(next * 10) ? old : next,
)
}
previous = now
request = requestAnimationFrame(tick)
}
request = requestAnimationFrame(tick)
return () => cancelAnimationFrame(request)
}, [playing, visible, clock, duration])

function seek(next: number) {
const clamped = Math.max(0, Math.min(next, duration))
clock.set(clamped)
setTime(clamped)
}

return {
figure,
clock,
time,
playing,
running: playing && visible,
compact,
reducedMotion,
restart: () => seek(0),
toggle: () => setPlaying((value) => !value),
seek: (next: number) => {
setPlaying(false)
seek(next)
},
}
}
Original file line number Diff line number Diff line change
@@ -1,40 +1,11 @@
.pq-demo {
container-type: inline-size;
--pq-bg: light-dark(#faf9f6, #171714);
--pq-panel: light-dark(#ffffff, #1f1f1b);
--pq-border: light-dark(#d9d6cc, #383830);
--pq-muted: light-dark(#686459, #a09e91);
--pq-ink: light-dark(#292820, #eeede5);
--pq-accent: light-dark(#a85b20, #eaa36d);
margin: 0;
color: var(--pq-ink);
background: var(--pq-bg);
border: 1px solid var(--pq-border);
border-radius: 8px;
overflow: hidden;
font:
14px/1.5 system-ui,
sans-serif;
color-scheme: light;
transition:
border-color 250ms ease,
box-shadow 250ms ease;
}
.pq-demo[data-running="true"] {
border-color: color-mix(in srgb, var(--pq-accent) 60%, var(--pq-border));
box-shadow: 0 0 12px color-mix(in srgb, var(--pq-accent) 18%, transparent);
}
.dark .pq-demo {
color-scheme: dark;
}
.pq-demo * {
box-sizing: border-box;
--pq-bg: var(--animation-bg);
--pq-panel: var(--animation-panel);
--pq-border: var(--animation-border);
--pq-muted: var(--animation-muted);
--pq-ink: var(--animation-ink);
--pq-accent: var(--animation-accent);
}
.pq-demo button {
font: inherit;
cursor: pointer;
}
.pq-demo button:focus-visible,
.pq-scroll:focus-visible {
outline: 2px solid var(--pq-accent);
outline-offset: -3px;
Expand Down Expand Up @@ -306,36 +277,6 @@
padding: 23px 28px;
color: light-dark(#514d42, #cbc9bc);
}
.pq-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 24px 22px;
}
.pq-controls > div {
display: flex;
gap: 6px;
}
.pq-controls button {
border: 1px solid var(--pq-border);
border-radius: 4px;
background: transparent;
color: var(--pq-ink);
padding: 7px 13px;
font-size: 12px;
min-width: 76px;
}
.pq-controls .pq-play {
background: light-dark(#eeece4, #292923);
}
.pq-controls button:hover:not(:disabled) {
border-color: var(--pq-muted);
}
.pq-controls button:disabled {
opacity: 0.3;
cursor: default;
}
.pq-demo figcaption {
margin: 0;
border-top: 1px solid var(--pq-border);
Expand Down Expand Up @@ -367,17 +308,7 @@
min-height: 106px;
padding: 20px;
}
.pq-controls {
padding: 0 16px 20px;
flex-wrap: wrap;
}
.pq-demo figcaption {
padding: 18px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.pq-demo,
.pq-demo * {
transition: none;
}
}
Loading
Loading