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>
);
}
















