TanStack
Catalog

Staggered entrance motion

motion

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 },
]