DHxWhy

Allocation Slider – Split 100% Across Segments with Draggable Dividers and Locks

Forms 3

About

Allocation Slider for React and Tailwind CSS (shadcn/ui): drag a divider between segments and its two neighbours trade share, so the total always stays at 100. It is built to make splitting a whole feel direct: only the two segments you move roll to new shares and amounts while the rest hold still. Locked segments are skipped over, minimums stop the drag, double-click evens neighbours, Reset restores the start. Controlled or uncontrolled, keyboard sliders with aria-valuetext, reduced motion, light and dark. Use it for budgets, portfolio weights or team time. The included demo drags, locks and resets a monthly budget and makes no requests. Requires React only, with no animation library.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Allocation Slider – Split 100% Across Segments with Draggable Dividers and Locks to your project and fits it to your theme.

Code

How Allocation Slider – Split 100% Across Segments with Draggable Dividers and Locks is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { AllocationSlider } from "@/components/ui/allocation-slider";
import type { AllocationSegment } from "@/components/ui/allocation-slider";

const SEGMENTS: AllocationSegment[] = [
  { id: "rent", label: "Rent", color: "chart-1", min: 10 },
  { id: "food", label: "Food", color: "chart-2", min: 5 },
  { id: "savings", label: "Savings", color: "chart-3", min: 5 },
  { id: "fun", label: "Fun", color: "chart-4" },
];
const SHARES = [40, 25, 20, 15];
const TOTAL = 3200;
const CYCLE = 9000;
const POINTER_ID = 9001;
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

const easeInOut = (progress: number) => (progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2);

function fire(target: EventTarget, type: string, clientX: number, clientY: number) {
  const released = type === "pointerup" || type === "pointercancel";
  target.dispatchEvent(new PointerEvent(type, {
    bubbles: true, cancelable: true, composed: true, pointerId: POINTER_ID, pointerType: "mouse", isPrimary: true,
    button: 0, buttons: released ? 0 : 1, clientX, clientY,
  }));
}

/** Drives the divider through the same pointer path a hand would: down on the handle, eased moves, a pause, then up. */
function simulateDrag(root: HTMLElement, index: number, deltaPercent: number, duration: number, hold: number, done: () => void) {
  const handle = root.querySelector(`[data-divider="${index}"]`);
  const track = root.querySelector("[data-track]");
  if (!(handle instanceof HTMLElement) || !(track instanceof HTMLElement)) { done(); return () => {}; }
  const box = handle.getBoundingClientRect();
  const x0 = box.left + box.width / 2;
  const y = box.top + box.height / 2;
  const x1 = x0 + (deltaPercent / 100) * track.getBoundingClientRect().width;
  fire(handle, "pointerdown", x0, y);
  let frame = 0;
  let timer = 0;
  let released = false;
  const started = performance.now();
  const tick = (now: number) => {
    const progress = Math.min(1, (now - started) / duration);
    fire(window, "pointermove", x0 + (x1 - x0) * easeInOut(progress), y);
    if (progress < 1) { frame = requestAnimationFrame(tick); return; }
    timer = window.setTimeout(() => { released = true; fire(window, "pointerup", x1, y); done(); }, hold);
  };
  frame = requestAnimationFrame(tick);
  return () => {
    cancelAnimationFrame(frame);
    window.clearTimeout(timer);
    if (!released) { released = true; fire(window, "pointercancel", x0, y); }
  };
}

function press(root: HTMLElement, selector: string) {
  const node = root.querySelector(selector);
  if (node instanceof HTMLElement) node.click();
}

export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
  const params = React.useMemo(() => new URLSearchParams(typeof location === "undefined" ? "" : location.search), []);
  const controlled = params.get("controlled") === "1";
  const freeze = params.get("freeze") === "1";
  const step = Number(params.get("step") ?? 1) || 1;
  const lockParam = params.get("lock") ?? "";
  const segments = React.useMemo(() => {
    const ids = lockParam.split(",").filter(Boolean);
    return SEGMENTS.map((segment) => ({ ...segment, locked: ids.includes(segment.id) }));
  }, [lockParam]);
  const rootRef = React.useRef(null as HTMLDivElement | null);
  const [values, setValues] = React.useState(SHARES);
  const [requested, setRequested] = React.useState("");
  const [committed, setCommitted] = React.useState("");
  const [locks, setLocks] = React.useState("");
  const [stopped, setStopped] = React.useState(false);
  const [stage, setStage] = React.useState("idle");

  React.useEffect(() => {
    if (!autoplay || stopped) return;
    const root = rootRef.current;
    if (!root) return;
    const timers: number[] = [];
    let cancelDrag = () => {};
    const run = () => {
      const at = (ms: number, task: () => void) => timers.push(window.setTimeout(task, ms));
      at(800, () => { setStage("drag-rent"); cancelDrag = simulateDrag(root, 0, 8, 1000, 200, () => setStage("idle")); });
      at(2600, () => { setStage("lock-savings"); press(root, '[data-lock="savings"]'); });
      at(3400, () => { setStage("drag-fun"); cancelDrag = simulateDrag(root, 2, -7, 1000, 600, () => setStage("idle")); });
      at(5600, () => { setStage("reset"); press(root, "[data-reset]"); });
      at(6100, () => setStage("idle"));
    };
    run();
    const loop = window.setInterval(run, CYCLE);
    return () => {
      window.clearInterval(loop);
      for (const timer of timers) window.clearTimeout(timer);
      cancelDrag();
    };
  }, [autoplay, stopped]);

  return (
    <main
      data-demo-root
      data-stage={stage}
      data-playing={autoplay && !stopped}
      data-requested={requested}
      data-committed={committed}
      data-locks={locks}
      className="flex min-h-[min(100svh,760px)] w-full items-center justify-center bg-background px-6 py-8 text-foreground"
    >
      <div ref={rootRef} className="w-full max-w-[560px] rounded-2xl bg-card p-5 text-card-foreground shadow-sm ring-1 ring-border sm:p-6">
        <AllocationSlider
          segments={segments}
          step={step}
          title="Monthly budget"
          total={params.get("nototal") === "1" ? undefined : TOTAL}
          formatAmount={(amount) => money.format(amount)}
          aria-label="Monthly budget allocation"
          {...(controlled ? { value: values } : { defaultValue: SHARES })}
          onValueChange={(next) => { setRequested(next.join(",")); if (controlled && !freeze) setValues(next); }}
          onValueCommit={(next) => setCommitted(next.join(","))}
          onLockedChange={(id, locked) => setLocks(`${id}:${locked ? "locked" : "unlocked"}`)}
          onInteractionStart={(event) => { if (event.isTrusted) setStopped(true); }}
        />
      </div>
    </main>
  );
}

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    900
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    851
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    444
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    331
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    280
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    248
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    222

More in Forms

View all
  • Premium Auth

    Premium Auth

    543
  • Contact Card

    Contact Card

    1.1K
  • Form

    Form

    17
  • Card Studio

    Card Studio

    46
  • Book A Demo 1

    Book A Demo 1

    80
  • Login 2

    Login 2

    10
  • Login 1

    Login 1

    58
  • Input

    Input

    14