303 lines · 3 files · 7.7 kB
cases/112-motion-entrance/controls.tsx96 lines · dependency
cases/112-motion-entrance/controls.tsx
import { forwardRef, type ReactNode } from 'react'
export const ControlBar = forwardRef<
HTMLDivElement,
{ label: string; children: ReactNode }
>(function ControlBar({ label, children }, ref) {
return (
<div
ref={ref}
role="group"
aria-label={label}
style={{
display: 'flex',
alignItems: 'center',
alignContent: 'center',
flexWrap: 'wrap',
gap: '8px 12px',
padding: '8px 10px',
font: '500 12px/1.2 system-ui, sans-serif',
}}
>
{children}
</div>
)
})
export function ControlField({
children,
label,
}: {
children: ReactNode
label: string
}) {
return (
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
}}
>
{label}
{children}
</label>
)
}
export const ControlButton = forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(function ControlButton({ children, style, type = 'button', ...props }, ref) {
return (
<button
{...props}
ref={ref}
type={type}
style={{ padding: '0 14px', ...style }}
>
{children}
</button>
)
})
export function RangeField({
label,
max,
min,
onChange,
step,
suffix = '',
value,
}: {
label: string
max: number
min: number
onChange: (value: number) => void
step: number
suffix?: string
value: number
}) {
return (
<ControlField label={label}>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.currentTarget.value))}
style={{ width: 96 }}
/>
<output>{`${value}${suffix}`}</output>
</ControlField>
)
}cases/112-motion-entrance/example.tsx189 lines · entry
cases/112-motion-entrance/example.tsx
import { useMemo, useRef, useState } from 'react'
import { barY, defineChart, lineY } from '@tanstack/charts'
import { motion } from '@tanstack/charts/motion'
import { Chart } from '@tanstack/charts/react/core'
import { scaleBand, scaleLinear } from 'd3-scale'
import { ControlBar, ControlButton, ControlField, RangeField } from './controls'
import { entranceRows as rows } from './model'
import type { ChartMotionTweenTransition } from '@tanstack/charts'
export interface MotionSettings {
duration: number
staggerMs: number
easing: ChartMotionTweenTransition['easing']
customTiming: boolean
}
const initialSettings: MotionSettings = {
duration: 1_100,
staggerMs: 55,
easing: undefined,
customTiming: true,
}
export function motionEntranceDefinition(settings: MotionSettings) {
const { duration, easing, staggerMs, customTiming } = settings
return defineChart({
motion: {
transition: { type: 'tween', duration, easing },
},
marks: [
barY(rows, {
x: 'period',
y: 'actual',
key: 'id',
fill: '#7c3aed',
radius: 7,
inset: 4,
motion(context) {
if (customTiming && context.datum?.featured) {
return {
delay: duration * 0.19,
transition: { type: 'tween', duration: duration * 0.64 },
}
}
return { delay: context.datumIndex * staggerMs }
},
}),
lineY(rows, {
x: 'period',
y: 'target',
key: 'id',
stroke: '#f97316',
strokeWidth: 3,
motion: customTiming
? {
delay: duration * 0.1,
transition: { type: 'tween', duration: duration * 0.78 },
}
: undefined,
}),
],
scales: {
x: { scale: scaleBand().domain(rows.map((row) => row.period)) },
y: { scale: scaleLinear().domain([0, 100]) },
},
guides: false,
margin: { top: 20, right: 20, bottom: 20, left: 20 },
})
}
function readEasing(value: string): MotionSettings['easing'] {
return value === 'linear' ||
value === 'ease' ||
value === 'ease-in' ||
value === 'ease-out' ||
value === 'ease-in-out'
? value
: undefined
}
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
export default function MotionEntranceExample({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '112-motion-entrance'
const viewRef = useRef<HTMLDivElement>(null)
const replayRef = useRef<HTMLButtonElement>(null)
const [settings, setSettings] = useState(initialSettings)
const [replayCount, setReplayCount] = useState(1)
const renderer = useMemo(() => motion(), [replayCount])
const definition = useMemo(
() => motionEntranceDefinition(settings),
[settings],
)
const replay = () => setReplayCount((value) => value + 1)
const changeSettings = (next: Partial<MotionSettings>) => {
setSettings((current) => ({ ...current, ...next }))
replay()
}
return (
<div
ref={viewRef}
data-conformance-view="main"
style={{
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
width: input.width,
height: input.height,
color: 'CanvasText',
}}
>
<ControlBar label="Entrance motion controls">
<RangeField
label="Duration"
min={300}
max={1_800}
step={100}
suffix=" ms"
value={settings.duration}
onChange={(duration) => changeSettings({ duration })}
/>
<RangeField
label="Stagger"
min={0}
max={120}
step={5}
suffix=" ms"
value={settings.staggerMs}
onChange={(staggerMs) => changeSettings({ staggerMs })}
/>
<ControlField label="Easing">
<select
value={
typeof settings.easing === 'string' ? settings.easing : 'polished'
}
onChange={(event) =>
changeSettings({ easing: readEasing(event.currentTarget.value) })
}
>
<option value="polished">Polished</option>
<option value="ease">Ease</option>
<option value="ease-out">Ease out</option>
<option value="ease-in-out">Ease in/out</option>
<option value="linear">Linear</option>
</select>
</ControlField>
<ControlField label="Apr + line timing">
<input
type="checkbox"
checked={settings.customTiming}
onChange={(event) =>
changeSettings({ customTiming: event.currentTarget.checked })
}
/>
</ControlField>
<ControlButton ref={replayRef} onClick={replay}>
Replay
</ControlButton>
</ControlBar>
<Chart
key={replayCount}
idPrefix={idPrefix}
definition={definition}
renderer={renderer}
width={input.width}
height={Math.max(180, input.height - 58)}
ariaLabel="Staggered monthly actuals and target"
style={{ minHeight: 0 }}
/>
</div>
)
}cases/112-motion-entrance/model.ts18 lines · dependency
cases/112-motion-entrance/model.ts
export interface MotionRow {
id: string
period: string
actual: number
target: number
featured?: boolean
}
export const entranceRows: readonly MotionRow[] = [
{ id: 'jan', period: 'Jan', actual: 26, target: 30 },
{ id: 'feb', period: 'Feb', actual: 38, target: 34 },
{ id: 'mar', period: 'Mar', actual: 44, target: 42 },
{ id: 'apr', period: 'Apr', actual: 58, target: 49, featured: true },
{ id: 'may', period: 'May', actual: 52, target: 55 },
{ id: 'jun', period: 'Jun', actual: 69, target: 62 },
{ id: 'jul', period: 'Jul', actual: 76, target: 70 },
{ id: 'aug', period: 'Aug', actual: 84, target: 79 },
]