Baktiyar Ablaikhan

Budget slider

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Drag the slider to set a budget and watch every category follow.

Code

"use client";

import { animate, motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from "motion/react";
import { useRef, useState } from "react";
import { Surface } from "../ui";

const MIN = 500;
const MAX = 6000;
const SPLIT = [
  { name: "Rent", share: 0.46, color: "#151515" },
  { name: "Food", share: 0.22, color: "#2B46B8" },
  { name: "Travel", share: 0.18, color: "#8ea0e6" },
  { name: "Other", share: 0.14, color: "#c9c8c3" },
];

const fmt = (n: number) => `$${Math.round(n / 10) * 10}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",");

export default function BudgetSlider() {
  const track = useRef<HTMLDivElement>(null);
  const t = useMotionValue(0.35);
  const smooth = useSpring(t, { stiffness: 400, damping: 40 });
  const left = useTransform(smooth, (v) => `${v * 100}%`);
  const [amount, setAmount] = useState(MIN + 0.35 * (MAX - MIN));
  const [press, setPress] = useState(false);
  useMotionValueEvent(smooth, "change", (v) => setAmount(MIN + v * (MAX - MIN)));

  const at = (x: number) => {
    const r = track.current!.getBoundingClientRect();
    return Math.min(1, Math.max(0, (x - r.left) / r.width));
  };

  return (
    <Surface>
      <div className="w-[20em] rounded-[1.3em] bg-white p-[1.3em] shadow-[0_12px_32px_-18px_rgba(0,0,0,0.35)]">
        <p className="text-[0.75em] text-[#8a8984]">Monthly budget</p>
        <p className="mt-[0.1em] text-[2.2em] font-medium tabular-nums tracking-[-0.02em]">{fmt(amount)}</p>
        <div
          ref={track}
          className="relative mt-[1em] h-[2.4em] cursor-pointer touch-none"
          onPointerDown={(e) => {
            e.currentTarget.setPointerCapture(e.pointerId);
            setPress(true);
            t.set(at(e.clientX));
          }}
          onPointerMove={(e) => press && t.set(at(e.clientX))}
          onPointerUp={() => {
            setPress(false);
            // snap to a round hundred
            const snapped = (Math.round((MIN + t.get() * (MAX - MIN)) / 100) * 100 - MIN) / (MAX - MIN);
            animate(t, snapped, { duration: 0.2 });
          }}
          onPointerCancel={() => setPress(false)}
        >
          <div className="absolute inset-x-0 top-1/2 h-[0.5em] -translate-y-1/2 rounded-full bg-[#ecebe6]" />
          <motion.div className="absolute left-0 top-1/2 h-[0.5em] -translate-y-1/2 rounded-full bg-[#151515]" style={{ width: left }} />
          <motion.div className="absolute top-1/2" style={{ left }}>
            <motion.div
              animate={{ scale: press ? 1.25 : 1 }}
              className="-ml-[0.8em] -mt-[0.8em] h-[1.6em] w-[1.6em] rounded-full border-[0.3em] border-[#151515] bg-white shadow-[0_4px_10px_-4px_rgba(0,0,0,0.5)]"
            />
            <motion.span
              animate={{ opacity: press ? 1 : 0, y: press ? 0 : 6 }}
              className="pointer-events-none absolute bottom-[1.3em] left-0 -translate-x-1/2 whitespace-nowrap rounded-full bg-[#151515] px-[0.6em] py-[0.15em] text-[0.7em] text-white"
            >
              {fmt(amount)}
            </motion.span>
          </motion.div>
        </div>
        <div className="mt-[0.8em] flex h-[0.6em] gap-[0.2em] overflow-hidden rounded-full">
          {SPLIT.map((s) => (
            <div key={s.name} style={{ flex: s.share, background: s.color }} />
          ))}
        </div>
        <div className="mt-[0.8em] grid grid-cols-2 gap-x-[1em] gap-y-[0.3em] text-[0.75em]">
          {SPLIT.map((s) => (
            <div key={s.name} className="flex items-center justify-between">
              <span className="flex items-center gap-[0.4em] text-[#8a8984]">
                <span className="h-[0.6em] w-[0.6em] rounded-full" style={{ background: s.color }} />
                {s.name}
              </span>
              <span className="tabular-nums">{fmt(amount * s.share)}</span>
            </div>
          ))}
        </div>
      </div>
    </Surface>
  );
}
Prompt

Build "Budget slider", a input animation. What it does: Drag the slider to set a budget and watch every category follow. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).