TanStack
Catalog

Themed interactive area card

theme

455 lines · 3 files · 12.2 kB

cases/120-themed-interactive-area/example.tsx302 lines · entry
cases/120-themed-interactive-area/example.tsx
import { useMemo, useState } from 'react'
import { defineChart } from '@tanstack/charts'
import { motion } from '@tanstack/charts/motion'
import { RendererChart } from '@tanstack/charts/react/tooltip'
import { tooltip } from '@tanstack/charts/tooltip'
import {
  formatThemedAreaTooltip,
  themedAreaRanges,
  themedAreaRangeDays,
  themedAreaRows,
} from './model'
import type { ChartTooltipOptions } from '@tanstack/charts'
import type { ThemedAreaRange, ThemedAreaRow } from './model'
import './styles.css'
import { areaY, crosshair, d3Curve, dot, lineY } from '@tanstack/charts'
import { focusGroupX } from '@tanstack/charts/focus'
import { decorative } from '@tanstack/charts/mark/decorative'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { curveMonotoneX } from 'd3-shape'
import { formatThemedAreaTick } from './model'
import type { ChartMotionSpringTransition } from '@tanstack/charts'
export const exampleAriaLabel =
  'Daily visitors with selectable 7, 30, and 90 day ranges'

export const themedAreaTooltip: ChartTooltipOptions<ThemedAreaRow> = {
  anchor: 'point',
  placement: ['top', 'right', 'left', 'bottom'],
  className: 'themed-area-tooltip',
  offset: 10,
  format: ({ datum }) => formatThemedAreaTooltip(datum),
}

export function createExampleChart(
  range: ThemedAreaRange,
  revision: number,
  width: number,
  height: number,
  previewMode = false,
) {
  const rows = themedAreaRows(range, revision)
  const input = {
    width,
    height,
    preview: previewMode,
  }

  const first = rows[0]
  const last = rows.at(-1)
  if (!first || !last) throw new TypeError('The themed area needs data')

  const maximum = Math.max(...rows.map((row) => row.visitors))
  const yMaximum = Math.max(500, Math.ceil((maximum * 1.08) / 100) * 100)
  const preview = input.preview === true
  const dateTicks = evenlySpacedDates(first.date, last.date, preview ? 3 : 5)

  return defineChart(
    {
      motion: { transition: themedAreaSpring },
      marks: [
        decorative(
          areaY(rows, {
            id: 'visitor-area',
            x: 'date',
            y: 'visitors',
            key: 'id',
            fill: 'url(#themed-area-fill)',
            fillOpacity: 1,
            curve: smooth,
          }),
        ),
        decorative(
          lineY(rows, {
            id: 'visitor-line',
            x: 'date',
            y: 'visitors',
            key: 'id',
            stroke: accent,
            strokeWidth: preview ? 2 : 2.4,
            curve: smooth,
          }),
        ),
        dot(rows, {
          id: 'visitor-points',
          x: 'date',
          y: 'visitors',
          key: 'id',
          r: 3,
          fill: accent,
          fillOpacity: 0,
          stroke: surface,
          strokeOpacity: 0,
          strokeWidth: 2,
          states: [
            {
              when: { focus: 'group' },
              style: {
                fillOpacity: 1,
                strokeOpacity: 1,
                r: preview ? 3.75 : 4.5,
              },
              transition: {
                type: 'tween',
                duration: 140,
                easing: 'ease-out',
              },
            },
            {
              when: { focus: 'primary' },
              style: {
                fillOpacity: 1,
                strokeOpacity: 1,
                r: preview ? 4.25 : 5,
              },
              transition: {
                type: 'tween',
                duration: 140,
                easing: 'ease-out',
              },
            },
          ],
        }),
        crosshair<Date, number>({
          id: 'visitor-crosshair',
          x: {
            stroke: grid,
            strokeOpacity: 0.34,
            strokeWidth: 1,
            strokeDasharray: '3 4',
          },
          y: false,
          motion: { transition: themedAreaSpring },
        }),
      ],
      scales: {
        x: {
          scale: scaleUtc().domain([first.date, last.date]),
          axis: {
            line: false,
            ticks: {
              values: dateTicks,
              size: 0,
              padding: preview ? 5 : 8,
              format: formatThemedAreaTick,
            },
            tickLabels: {
              fontSize: preview ? 8 : 10,
              opacity: 0.62,
              thin: { minGap: preview ? 5 : 12, priority: 'ends' },
            },
          },
        },
        y: {
          scale: scaleLinear().domain([0, yMaximum]),
          grid: true,
          axis: {
            line: false,
            ticks: {
              values: [0, yMaximum / 3, (yMaximum * 2) / 3, yMaximum],
              size: 0,
            },
            tickLabels: false,
          },
        },
      },

      gradients: [
        {
          id: 'themed-area-fill',
          x1: 0,
          y1: 0,
          x2: 0,
          y2: 1,
          stops: [
            { offset: 0, color: accent, opacity: 0.34 },
            { offset: 0.58, color: accent, opacity: 0.13 },
            { offset: 1, color: accent, opacity: 0.015 },
          ],
        },
      ],
      theme: {
        background: 'transparent',
        foreground,
        muted,
        grid,
        palette: [accent],
      },
      focus: focusGroupX,
      focusRing: false,
      maxFocusDistance: Number.POSITIVE_INFINITY,
      tooltip: false,
      keyboard: true,
      clip: true,
      margin: preview
        ? { top: 10, right: 18, bottom: 22, left: 18 }
        : { top: 12, right: 18, bottom: 28, left: 18 },
    },
    {
      keyboard: true,
      tooltip: { use: tooltip, ...themedAreaTooltip },
    },
  )
}

export interface ExampleProps {
  width?: number
  height?: number
  revision?: number
}

export default function Example({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const [range, setRange] = useState<ThemedAreaRange>('30d')
  const definition = useMemo(
    () => createExampleChart(range, revision, width, height - 70),
    [height, range, revision, width],
  )
  const renderer = useMemo(
    () =>
      motion({
        initial: 'always',
        transition: themedAreaSpring,
        respectReducedMotion: true,
      }),
    [],
  )

  return (
    <section className="themed-area-card" style={{ width, height }}>
      <header className="themed-area-card__header">
        <div>
          <h2 className="themed-area-card__title">Traffic</h2>
          <p className="themed-area-card__description">Daily visitors</p>
        </div>
        <div
          className="themed-area-card__ranges"
          role="group"
          aria-label="Date range"
        >
          {themedAreaRanges.map((value) => (
            <button
              key={value}
              type="button"
              className="themed-area-card__range"
              aria-label={`Last ${themedAreaRangeDays[value]} days`}
              aria-pressed={value === range}
              onClick={() => setRange(value)}
            >
              {value.toUpperCase()}
            </button>
          ))}
        </div>
      </header>
      <RendererChart
        definition={definition}
        renderer={renderer}
        width={width}
        height={Math.max(120, height - 70)}
        ariaLabel={exampleAriaLabel}
        ariaDescription="Choose a range, then move across the chart or use the arrow keys to inspect daily visitor totals."
      />
    </section>
  )
}

export interface ThemedAreaChartInput {
  width: number
  height: number
  preview?: boolean
}

export const themedAreaSpring = {
  type: 'spring',
  stiffness: 190,
  damping: 24,
  mass: 0.82,
  restDelta: 0.02,
  restSpeed: 0.02,
} satisfies ChartMotionSpringTransition

const smooth = d3Curve(curveMonotoneX)

const accent = 'var(--themed-area-accent, #3b82f6)'

const surface = 'var(--themed-area-surface, Canvas)'

const foreground = 'var(--themed-area-foreground, CanvasText)'

const muted = 'var(--themed-area-muted, GrayText)'

const grid = 'var(--themed-area-grid, CanvasText)'

function evenlySpacedDates(first: Date, last: Date, count: number) {
  const start = first.getTime()
  const span = last.getTime() - start
  return Array.from(
    { length: count },
    (_value, index) => new Date(start + (span * index) / (count - 1)),
  )
}
cases/120-themed-interactive-area/model.ts75 lines · dependency
cases/120-themed-interactive-area/model.ts
export const themedAreaRanges = ['7d', '30d', '90d'] as const

export type ThemedAreaRange = (typeof themedAreaRanges)[number]

export interface ThemedAreaRow {
  id: string
  date: Date
  visitors: number
}

export const themedAreaRangeDays: Record<ThemedAreaRange, number> = {
  '7d': 7,
  '30d': 30,
  '90d': 90,
}

const lastDay = Date.UTC(2026, 5, 30)
const day = 24 * 60 * 60 * 1_000

const dateFormatter = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  timeZone: 'UTC',
})

const tooltipDateFormatter = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  year: 'numeric',
  timeZone: 'UTC',
})

export function themedAreaRows(
  range: ThemedAreaRange,
  revision = 0,
): readonly ThemedAreaRow[] {
  const rows = Array.from({ length: 90 }, (_value, index) => {
    const date = new Date(lastDay - (89 - index) * day)
    const weekly = Math.sin((index / 7) * Math.PI * 2) * 34
    const season = Math.sin(((index + 5) / 29) * Math.PI * 2) * 72
    const trend = index * 0.82
    const campaigns =
      pulse(index, 20, 4.5, 92) +
      pulse(index, 51, 6, 126) +
      pulse(index, 77, 3.8, 108)
    const revisionShift =
      Math.sin(((index + revision * 3) / 11) * Math.PI * 2) *
      Math.min(26, Math.abs(revision) * 6)
    const visitors = Math.max(
      96,
      Math.round(238 + weekly + season + trend + campaigns + revisionShift),
    )

    return {
      id: date.toISOString().slice(0, 10),
      date,
      visitors,
    }
  })

  return rows.slice(-themedAreaRangeDays[range])
}

export function formatThemedAreaTick(date: Date) {
  return dateFormatter.format(date)
}

export function formatThemedAreaTooltip(row: ThemedAreaRow) {
  return `${tooltipDateFormatter.format(row.date)} · ${row.visitors.toLocaleString('en-US')} visitors`
}

function pulse(index: number, center: number, width: number, height: number) {
  const distance = (index - center) / width
  return Math.exp(-(distance * distance)) * height
}
cases/120-themed-interactive-area/styles.css78 lines · dependency
cases/120-themed-interactive-area/styles.css
.themed-area-card {
  --themed-area-surface: light-dark(#ffffff, #09090b);
  --themed-area-foreground: light-dark(#18181b, #fafafa);
  --themed-area-muted: light-dark(#71717a, #a1a1aa);
  --themed-area-grid: light-dark(#64748b, #94a3b8);
  --themed-area-border: light-dark(
    rgba(15, 23, 42, 0.11),
    rgba(255, 255, 255, 0.1)
  );
  --themed-area-accent: light-dark(#2563eb, #60a5fa);
  --ts-chart-tooltip-background: color-mix(
    in srgb,
    var(--themed-area-surface) 94%,
    transparent
  );
  --ts-chart-tooltip-color: var(--themed-area-foreground);
  --ts-chart-tooltip-border: 1px solid var(--themed-area-border);
  --ts-chart-tooltip-border-radius: 8px;
  --ts-chart-tooltip-shadow: 0 8px 24px rgba(15, 23, 42, 0.13);
  box-sizing: border-box;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  overflow: hidden;
  color: var(--themed-area-foreground);
  background: var(--themed-area-surface);
  border: 1px solid var(--themed-area-border);
  border-radius: 16px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.themed-area-card__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 18px 20px 8px;
}

.themed-area-card__title {
  margin: 0;
  font-size: 14px;
  line-height: 1.25;
}

.themed-area-card__description {
  margin: 4px 0 0;
  color: var(--themed-area-muted);
  font-size: 11px;
}

.themed-area-card__ranges {
  display: inline-flex;
  gap: 2px;
  padding: 3px;
  border: 1px solid var(--themed-area-border);
  border-radius: 9px;
}

.themed-area-card__range {
  min-width: 38px;
  min-height: 30px;
  padding: 0 9px;
  color: var(--themed-area-muted);
  background: transparent;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
  font:
    600 10px/1 ui-sans-serif,
    system-ui,
    sans-serif;
}

.themed-area-card__range[aria-pressed='true'] {
  color: var(--themed-area-foreground);
  background: var(--themed-area-surface);
  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.11);
}