Math Graph

A real-time visual math function builder with animated SVG curves, pan & zoom, crosshair tooltip, and support for multiple simultaneous expressions.

Made by Léo
Open in
scroll to zoom · drag to pan
demo-math-graph.tsx
"use client";

import { MathGraph } from "@/components/unlumen-ui/math-graph";

interface MathGraphDemoProps {
  resolution: number;
  showGrid: boolean;
  showLabels: boolean;
  animated: boolean;
}

export const MathGraphDemo = ({
  resolution = 600,
  showGrid = true,
  showLabels = true,
  animated = true,
}: MathGraphDemoProps) => {
  return (
    <div className="w-full max-w-3xl mx-auto p-4">
      <MathGraph
        initialExpressions={["sin(x)", "cos(x)"]}
        resolution={resolution}
        showGrid={showGrid}
        showLabels={showLabels}
        animated={animated}
        className="w-full"
      />
    </div>
  );
};

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/math-graph.tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import useMeasure from "react-use-measure";

import { cn } from "@/lib/utils";

const CURVE_COLORS = ["#3b82f6", "#22d3ee", "#f472b6", "#a78bfa"];

const CURVE_BG = [
  "rgba(59,130,246,0.08)",
  "rgba(34,211,238,0.08)",
  "rgba(244,114,182,0.08)",
  "rgba(167,139,250,0.08)",
];

function evalMath(expr: string, x: number): number | null {
  try {
    const normalized = expr
      .replace(/\^/g, "**")
      .replace(/(\d)\s*x/g, "$1*x")
      .replace(/x\s*(\d)/g, "x*$1");

    // eslint-disable-next-line no-new-func
    const fn = new Function(
      "x",
      "sin",
      "cos",
      "tan",
      "asin",
      "acos",
      "atan",
      "atan2",
      "sinh",
      "cosh",
      "tanh",
      "sqrt",
      "cbrt",
      "abs",
      "log",
      "log2",
      "log10",
      "exp",
      "pow",
      "floor",
      "ceil",
      "round",
      "min",
      "max",
      "sign",
      "PI",
      "E",
      `"use strict"; return (${normalized});`,
    );

    const result = fn(
      x,
      Math.sin,
      Math.cos,
      Math.tan,
      Math.asin,
      Math.acos,
      Math.atan,
      Math.atan2,
      Math.sinh,
      Math.cosh,
      Math.tanh,
      Math.sqrt,
      Math.cbrt,
      Math.abs,
      Math.log,
      Math.log2,
      Math.log10,
      Math.exp,
      Math.pow,
      Math.floor,
      Math.ceil,
      Math.round,
      Math.min,
      Math.max,
      Math.sign,
      Math.PI,
      Math.E,
    );

    if (typeof result !== "number" || !isFinite(result) || isNaN(result))
      return null;
    return result;
  } catch {
    return null;
  }
}

function validateExpr(expr: string): boolean {
  if (!expr.trim()) return false;
  try {
    const r = evalMath(expr, 1);
    return r !== null || evalMath(expr, 0) !== null;
  } catch {
    return false;
  }
}

function buildPath(
  expr: string,
  xMin: number,
  xMax: number,
  yMin: number,
  yMax: number,
  width: number,
  height: number,
  steps = 500,
): string {
  if (!width || !height) return "";

  const toSX = (x: number) => ((x - xMin) / (xMax - xMin)) * width;
  const toSY = (y: number) => height - ((y - yMin) / (yMax - yMin)) * height;

  const yRange = yMax - yMin;
  const segments: string[] = [];
  let seg: string[] = [];
  let lastY: number | null = null;

  for (let i = 0; i <= steps; i++) {
    const mx = xMin + (i / steps) * (xMax - xMin);
    const my = evalMath(expr, mx);

    if (my === null) {
      if (seg.length > 1) segments.push(seg.join(" "));
      seg = [];
      lastY = null;
      continue;
    }

    if (lastY !== null && Math.abs(my - lastY) > yRange * 2) {
      if (seg.length > 1) segments.push(seg.join(" "));
      seg = [];
    }

    const sx = toSX(mx).toFixed(2);
    const sy = toSY(my).toFixed(2);
    seg.push(seg.length === 0 ? `M ${sx} ${sy}` : `L ${sx} ${sy}`);
    lastY = my;
  }

  if (seg.length > 1) segments.push(seg.join(" "));
  return segments.join(" ");
}

function niceStep(range: number, targetCount: number): number {
  const rough = range / targetCount;
  const pow10 = Math.pow(10, Math.floor(Math.log10(Math.abs(rough) || 1)));
  for (const n of [1, 2, 2.5, 5, 10]) {
    if (n * pow10 >= rough) return n * pow10;
  }
  return pow10 * 10;
}

function getTicks(min: number, max: number, step: number): number[] {
  const ticks: number[] = [];
  const start = Math.ceil(min / step) * step;
  for (let t = start; t <= max + 1e-9; t += step) {
    ticks.push(parseFloat(t.toPrecision(10)));
  }
  return ticks;
}

function fmtLabel(n: number): string {
  if (n === 0) return "0";
  if (Math.abs(n) >= 1000)
    return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
  if (Number.isInteger(n)) return String(n);
  return n.toPrecision(3).replace(/\.?0+$/, "");
}

type ExprEntry = {
  id: string;
  expr: string;
  color: string;
  bg: string;
  valid: boolean;
};

const DEFAULT_PRESETS = [
  "sin(x)",
  "x**2 / 4 - 2",
  "tan(x)",
  "exp(-x**2 / 2)",
  "abs(sin(x)) * 3",
  "log(abs(x) + 1)",
] as const;

interface MathGraphProps {
  initialExpressions?: string[];
  xMin?: number;
  xMax?: number;
  resolution?: number;
  showGrid?: boolean;
  showLabels?: boolean;
  animated?: boolean;
  className?: string;
}

function MathGraph({
  initialExpressions = ["sin(x)", "cos(x)"],
  xMin: initXMin = -2 * Math.PI,
  xMax: initXMax = 2 * Math.PI,
  resolution = 600,
  showGrid = true,
  showLabels = true,
  animated = true,
  className,
}: MathGraphProps) {
  const [measureRef, { width, height }] = useMeasure();

  const [xMin, setXMin] = React.useState(initXMin);
  const [xMax, setXMax] = React.useState(initXMax);
  const isDefaultView =
    Math.abs(xMin - initXMin) < 0.01 && Math.abs(xMax - initXMax) < 0.01;

  const [entries, setEntries] = React.useState<ExprEntry[]>(() =>
    initialExpressions.slice(0, 4).map((expr, i) => ({
      id: crypto.randomUUID(),
      expr,
      color: CURVE_COLORS[i % CURVE_COLORS.length]!,
      bg: CURVE_BG[i % CURVE_BG.length]!,
      valid: true,
    })),
  );

  // debounced to avoid recomputing expensive paths/yRange on every keystroke
  const [debounced, setDebounced] = React.useState<ExprEntry[]>(entries);
  React.useEffect(() => {
    const id = setTimeout(() => setDebounced(entries), 220);
    return () => clearTimeout(id);
  }, [entries]);

  // increments only on zoom (not pan) — used as motion.path key to replay the draw animation
  const [zoomKey, setZoomKey] = React.useState(0);
  const prevScaleRef = React.useRef(initXMax - initXMin);
  React.useEffect(() => {
    const newScale = xMax - xMin;
    if (
      Math.abs(newScale - prevScaleRef.current) / (prevScaleRef.current || 1) >
      5e-4
    ) {
      prevScaleRef.current = newScale;
      setZoomKey((k) => k + 1);
    }
  }, [xMin, xMax]);

  const [hover, setHover] = React.useState<{
    svgX: number;
    svgY: number;
    mathX: number;
  } | null>(null);

  const drag = React.useRef<{
    startSvgX: number;
    startXMin: number;
    startXMax: number;
  } | null>(null);

  const { yMin, yMax } = React.useMemo(() => {
    if (!width) return { yMin: -5, yMax: 5 };
    const ys: number[] = [];
    for (const e of debounced) {
      if (!e.valid || !e.expr) continue;
      for (let i = 0; i <= 300; i++) {
        const x = xMin + (i / 300) * (xMax - xMin);
        const y = evalMath(e.expr, x);
        if (y !== null) ys.push(y);
      }
    }
    if (ys.length === 0) return { yMin: -5, yMax: 5 };
    const lo = Math.min(...ys);
    const hi = Math.max(...ys);
    const pad = Math.max((hi - lo) * 0.18, 1.5);
    return { yMin: lo - pad, yMax: hi + pad };
  }, [debounced, xMin, xMax, width]);

  const toSX = React.useCallback(
    (x: number) => ((x - xMin) / (xMax - xMin)) * width,
    [xMin, xMax, width],
  );
  const toSY = React.useCallback(
    (y: number) => height - ((y - yMin) / (yMax - yMin)) * height,
    [yMin, yMax, height],
  );
  const toMX = React.useCallback(
    (sx: number) => xMin + (sx / width) * (xMax - xMin),
    [xMin, xMax, width],
  );

  const xStep = niceStep(xMax - xMin, Math.max(4, Math.floor(width / 80)));
  const yStep = niceStep(yMax - yMin, Math.max(3, Math.floor(height / 55)));
  const xTicks = getTicks(xMin, xMax, xStep);
  const yTicks = getTicks(yMin, yMax, yStep);
  const axisY = toSY(0);
  const axisX = toSX(0);

  const paths = React.useMemo(() => {
    if (!width || !height) return [];
    return debounced.map((e) => ({
      ...e,
      d:
        e.valid && e.expr
          ? buildPath(e.expr, xMin, xMax, yMin, yMax, width, height, resolution)
          : "",
    }));
  }, [debounced, xMin, xMax, yMin, yMax, width, height, resolution]);

  // non-passive wheel listener so e.preventDefault() blocks page scroll
  const graphDivRef = React.useRef<HTMLDivElement>(null);

  // stable refs so the wheel handler always has fresh values without re-creating
  const xMinRef = React.useRef(xMin);
  const xMaxRef = React.useRef(xMax);
  const widthRef = React.useRef(width);
  React.useEffect(() => {
    xMinRef.current = xMin;
  }, [xMin]);
  React.useEffect(() => {
    xMaxRef.current = xMax;
  }, [xMax]);
  React.useEffect(() => {
    widthRef.current = width;
  }, [width]);

  React.useEffect(() => {
    const el = graphDivRef.current;
    if (!el) return;
    const handler = (e: WheelEvent) => {
      e.preventDefault();
      const rect = el.getBoundingClientRect();
      const sx = e.clientX - rect.left;
      const curXMin = xMinRef.current;
      const curXMax = xMaxRef.current;
      const curWidth = widthRef.current;
      const mx = curXMin + (sx / curWidth) * (curXMax - curXMin);
      const factor = e.deltaY > 0 ? 1.14 : 0.88;
      setXMin(mx + (curXMin - mx) * factor);
      setXMax(mx + (curXMax - mx) * factor);
    };
    el.addEventListener("wheel", handler, { passive: false });
    return () => el.removeEventListener("wheel", handler);
  }, []);

  const handleMouseDown = React.useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      const rect = e.currentTarget.getBoundingClientRect();
      drag.current = {
        startSvgX: e.clientX - rect.left,
        startXMin: xMin,
        startXMax: xMax,
      };
    },
    [xMin, xMax],
  );

  const handleMouseMove = React.useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      const rect = e.currentTarget.getBoundingClientRect();
      const sx = e.clientX - rect.left;
      const sy = e.clientY - rect.top;
      const mx = toMX(sx);
      setHover({ svgX: sx, svgY: sy, mathX: mx });

      if (drag.current) {
        const dx = sx - drag.current.startSvgX;
        const range = drag.current.startXMax - drag.current.startXMin;
        const shift = -(dx / width) * range;
        setXMin(drag.current.startXMin + shift);
        setXMax(drag.current.startXMax + shift);
      }
    },
    [toMX, width],
  );

  const handleMouseUp = React.useCallback(() => {
    drag.current = null;
  }, []);

  const handleMouseLeave = React.useCallback(() => {
    drag.current = null;
    setHover(null);
  }, []);

  const updateEntry = React.useCallback((id: string, expr: string) => {
    setEntries((prev) =>
      prev.map((e) => {
        if (e.id !== id) return e;
        return { ...e, expr, valid: validateExpr(expr) };
      }),
    );
  }, []);

  const addEntry = React.useCallback(() => {
    if (entries.length >= 4) return;
    const idx = entries.length;
    setEntries((prev) => [
      ...prev,
      {
        id: crypto.randomUUID(),
        expr: "",
        color: CURVE_COLORS[idx % CURVE_COLORS.length]!,
        bg: CURVE_BG[idx % CURVE_BG.length]!,
        valid: false,
      },
    ]);
  }, [entries.length]);

  const removeEntry = React.useCallback((id: string) => {
    setEntries((prev) => prev.filter((e) => e.id !== id));
  }, []);

  const applyPreset = React.useCallback((expr: string) => {
    setEntries([
      {
        id: crypto.randomUUID(),
        expr,
        color: CURVE_COLORS[0]!,
        bg: CURVE_BG[0]!,
        valid: true,
      },
    ]);
  }, []);

  const resetView = React.useCallback(() => {
    setXMin(initXMin);
    setXMax(initXMax);
  }, [initXMin, initXMax]);

  return (
    <div
      className={cn(
        "flex flex-col select-none overflow-hidden rounded-xl border border-border bg-background",
        className,
      )}
    >
      <div className="flex flex-col gap-1.5 p-3 border-b border-border bg-muted/20">
        {entries.map((entry) => (
          <div key={entry.id} className="flex items-center gap-2">
            <div
              className="w-2.5 h-2.5 rounded-full shrink-0 ring-1 ring-white/10"
              style={{ backgroundColor: entry.color }}
            />

            <div className="relative flex-1">
              <input
                value={entry.expr}
                onChange={(ev) => updateEntry(entry.id, ev.target.value)}
                placeholder="e.g.  sin(x) * 2"
                spellCheck={false}
                autoComplete="off"
                className={cn(
                  "w-full h-8 bg-background border rounded-lg px-3 text-sm font-mono outline-none transition-all",
                  entry.expr && !entry.valid
                    ? "border-red-500/50 text-red-400 focus:border-red-400"
                    : "border-border/60 focus:border-ring",
                )}
              />
            </div>

            {entries.length > 1 && (
              <button
                onClick={() => removeEntry(entry.id)}
                className="shrink-0 w-6 h-6 flex items-center justify-center rounded-md text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors text-xs"
              >

              </button>
            )}
          </div>
        ))}

        <div className="flex items-center gap-1.5 flex-wrap pt-0.5">
          {entries.length < 4 && (
            <button
              onClick={addEntry}
              className="text-xs text-muted-foreground hover:text-foreground transition-colors h-6 px-2 rounded-md hover:bg-muted"
            >
              + function
            </button>
          )}
          <div className="flex-1" />
          {DEFAULT_PRESETS.map((p) => (
            <button
              key={p}
              onClick={() => applyPreset(p)}
              className="text-xs h-6 px-2 rounded-md border border-border/60 bg-background hover:bg-muted transition-colors font-mono text-muted-foreground hover:text-foreground"
            >
              {p}
            </button>
          ))}
        </div>
      </div>

      <div
        ref={(el) => {
          measureRef(el);
          (
            graphDivRef as React.MutableRefObject<HTMLDivElement | null>
          ).current = el;
        }}
        className="relative cursor-crosshair"
        style={{ height: 380 }}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseLeave}
      >
        <svg
          width={width}
          height={height}
          className="absolute inset-0 block"
          style={{ overflow: "visible" }}
        >
          {showGrid && width > 0 && (
            <g>
              {xTicks.map((t) => (
                <line
                  key={`xg-${t}`}
                  x1={toSX(t)}
                  y1={0}
                  x2={toSX(t)}
                  y2={height}
                  stroke="currentColor"
                  strokeWidth={0.5}
                  className="text-border"
                  opacity={0.5}
                />
              ))}
              {yTicks.map((t) => (
                <line
                  key={`yg-${t}`}
                  x1={0}
                  y1={toSY(t)}
                  x2={width}
                  y2={toSY(t)}
                  stroke="currentColor"
                  strokeWidth={0.5}
                  className="text-border"
                  opacity={0.5}
                />
              ))}
            </g>
          )}

          {width > 0 && height > 0 && (
            <g>
              {axisY >= 0 && axisY <= height && (
                <line
                  x1={0}
                  y1={axisY}
                  x2={width}
                  y2={axisY}
                  stroke="currentColor"
                  strokeWidth={1.5}
                  className="text-foreground/25"
                />
              )}
              {axisX >= 0 && axisX <= width && (
                <line
                  x1={axisX}
                  y1={0}
                  x2={axisX}
                  y2={height}
                  stroke="currentColor"
                  strokeWidth={1.5}
                  className="text-foreground/25"
                />
              )}
            </g>
          )}

          {showLabels && width > 0 && height > 0 && (
            <g
              fontSize={10}
              fontFamily="ui-monospace, monospace"
              className="text-muted-foreground"
            >
              {xTicks
                .filter((t) => Math.abs(t) > xStep * 0.01)
                .map((t) => {
                  const lx = toSX(t);
                  if (lx < 4 || lx > width - 4) return null;
                  const ly = Math.min(height - 4, Math.max(13, axisY + 14));
                  return (
                    <text
                      key={`xl-${t}`}
                      x={lx}
                      y={ly}
                      textAnchor="middle"
                      fill="currentColor"
                      opacity={0.55}
                    >
                      {fmtLabel(t)}
                    </text>
                  );
                })}
              {yTicks
                .filter((t) => Math.abs(t) > yStep * 0.01)
                .map((t) => {
                  const ly = toSY(t);
                  if (ly < 8 || ly > height - 4) return null;
                  const lx = Math.min(width - 4, Math.max(4, axisX - 6));
                  return (
                    <text
                      key={`yl-${t}`}
                      x={lx}
                      y={ly + 3}
                      textAnchor="end"
                      fill="currentColor"
                      opacity={0.55}
                    >
                      {fmtLabel(t)}
                    </text>
                  );
                })}
            </g>
          )}

          <AnimatePresence>
            {paths.map(({ id, d, color }) =>
              d ? (
                <motion.path
                  key={`${id}-${zoomKey}`}
                  d={d}
                  fill="none"
                  stroke={color}
                  strokeWidth={2.2}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={{ pathLength: 0, opacity: 0 }}
                  animate={{ pathLength: 1, opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{
                    pathLength: {
                      duration: animated ? 0.7 : 0,
                      ease: [0.4, 0, 0.2, 1],
                    },
                    opacity: { duration: 0.25 },
                  }}
                />
              ) : null,
            )}
          </AnimatePresence>

          {hover && (
            <>
              <line
                x1={hover.svgX}
                y1={0}
                x2={hover.svgX}
                y2={height}
                stroke="currentColor"
                strokeWidth={1}
                className="text-foreground/15"
                strokeDasharray="4 4"
              />
              {paths.map(({ id, expr, color, valid }) => {
                if (!valid || !expr) return null;
                const y = evalMath(expr, hover.mathX);
                if (y === null) return null;
                const cy = toSY(y);
                if (cy < -4 || cy > height + 4) return null;
                return (
                  <g key={id}>
                    <circle
                      cx={hover.svgX}
                      cy={cy}
                      r={5}
                      fill={color}
                      opacity={0.2}
                    />
                    <circle
                      cx={hover.svgX}
                      cy={cy}
                      r={3}
                      fill={color}
                      stroke="white"
                      strokeWidth={1.5}
                    />
                  </g>
                );
              })}
            </>
          )}
        </svg>

        <AnimatePresence>
          {hover && (
            <motion.div
              className="absolute pointer-events-none z-10 bg-background/90 backdrop-blur-sm border border-border/60 rounded-lg px-2.5 py-1.5 text-xs font-mono shadow-lg"
              style={{
                left:
                  hover.svgX > width * 0.68 ? hover.svgX - 12 : hover.svgX + 14,
                top: Math.max(6, hover.svgY - 44),
                transform:
                  hover.svgX > width * 0.68 ? "translateX(-100%)" : undefined,
              }}
              initial={{ opacity: 0, scale: 0.92 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.92 }}
              transition={{ duration: 0.1 }}
            >
              <div className="text-muted-foreground mb-1 text-[10px]">
                x = {hover.mathX.toFixed(3)}
              </div>
              {paths.map(({ id, expr, color, valid }) => {
                if (!valid || !expr) return null;
                const y = evalMath(expr, hover.mathX);
                if (y === null) return null;
                return (
                  <div
                    key={id}
                    className="leading-snug text-[10px]"
                    style={{ color }}
                  >
                    f = {y.toFixed(4)}
                  </div>
                );
              })}
            </motion.div>
          )}
        </AnimatePresence>

        <div className="absolute bottom-2 left-3 right-3 flex items-end justify-between pointer-events-none">
          <AnimatePresence>
            {!isDefaultView && (
              <motion.button
                initial={{ opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 4 }}
                transition={{ duration: 0.15 }}
                className="pointer-events-auto text-[10px] text-muted-foreground/70 hover:text-foreground transition-colors h-5 px-1.5 rounded bg-background/60 backdrop-blur-sm border border-border/40 hover:bg-muted"
                onClick={resetView}
              >
                reset view
              </motion.button>
            )}
          </AnimatePresence>
          <span className="text-[10px] text-muted-foreground/30 ml-auto">
            scroll to zoom · drag to pan
          </span>
        </div>
      </div>
    </div>
  );
}

export { MathGraph, type MathGraphProps };

Update the import paths to match your project setup.

Usage

import { MathGraph } from "@/components/unlumen-ui/components/effects/math-graph";

<MathGraph initialExpressions={["sin(x)", "cos(x)"]} />;

Supported syntax

The evaluator accepts standard JavaScript math syntax with a few convenience shorthands:

InputInterpreted as
sin(x), cos(x), tan(x)Trigonometric functions
sqrt(x), cbrt(x), abs(x)Root and absolute value
log(x), log2(x), log10(x), exp(x)Logarithmic / exponential
x^2x**2 (caret shorthand)
PI, EMath.PI, Math.E
floor, ceil, round, min, max, signRounding and comparison

Interactions

  • Scroll inside the graph to zoom in/out around the cursor
  • Drag horizontally to pan the x-axis
  • Hover to see exact coordinates and per-curve values at the crosshair
  • + function adds a second curve (up to 4 simultaneous expressions)
  • Preset buttons quickly load common functions
  • reset view restores the original x-range after panning/zooming

API Reference

MathGraph

initialExpressions?
string[]
["sin(x)", "cos(x)"]

Initial array of math expression strings (max 4).

xMin?
number
-2π

Initial left boundary of the x-axis.

xMax?
number

Initial right boundary of the x-axis.

resolution?
number
600

Number of sample points used to build each curve path.

showGrid?
boolean
true

Whether to render the background grid lines.

showLabels?
boolean
true

Whether to render tick labels on the axes.

animated?
boolean
true

Whether new curves animate their path-drawing on mount.

className?
string

Additional CSS classes applied to the root element.

Built by Léo from Unlumen :3

Last updated: 4/29/2026