Subframe
Components & interactionsLocked budget allocation

A budget that stays balanced

Changing one allocation redistributes the remaining budget across unlocked lines while the total stays exact.

Drag an allocation or use its arrow keys. Lock a line to preserve it while other amounts redistribute; Escape cancels the whole drag.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
"use client"
import * as React from "react"

export const DEFAULT_CONFIG = { totalUnits: 100, segmentCount: 4, minimumUnits: 5, settleMs: 160 }
type Config = typeof DEFAULT_CONFIG
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, Number.isFinite(v) ? v : min))
const LINES = ["Production", "Talent", "Post", "Reserve", "Travel", "Licensing"]
const COLORS = [
  { ink: "#4056d8", beam: "#5268ec", text: "#fff" },
  { ink: "#607b98", beam: "#a2b6ca", text: "#233342" },
  { ink: "#808991", beam: "#dce2e7", text: "#333d46" },
  { ink: "#35414c", beam: "#35414c", text: "#fff" },
  { ink: "#a26242", beam: "#d69a72", text: "#3b271c" },
  { ink: "#6c7760", beam: "#bbc4a9", text: "#30372a" },
]
const money = (value: number) => `$${(value * 1000).toLocaleString("en-US")}`
export function distribute(values: number[], locks: boolean[], index: number, next: number, floor: number) {
  const total = values.reduce((a, b) => a + b, 0)
  const others = values.map((_, i) => i).filter((i) => i !== index && !locks[i])
  if (locks[index] || !others.length) {
    return values
  }
  const locked = values.reduce((sum, value, i) => sum + (locks[i] ? value : 0), 0)
  const target = Math.round(clamp(next, floor, total - locked - floor * others.length))
  const available = total - locked - target - floor * others.length
  const weight = others.reduce((sum, i) => sum + Math.max(0, values[i] - floor), 0)
  const raw = others.map((i) => ({
    i,
    n: available * (weight ? Math.max(0, values[i] - floor) / weight : 1 / others.length),
  }))
  const result = [...values]
  result[index] = target
  raw.forEach(({ i, n }) => {
    result[i] = floor + Math.floor(n)
  })
  let remainder = total - result.reduce((a, b) => a + b, 0)
  raw
    .sort((a, b) => (b.n % 1) - (a.n % 1) || a.i - b.i)
    .forEach(({ i }) => {
      if (remainder-- > 0) {
        result[i]++
      }
    })
  return result
}
function seed(total: number, count: number, floor: number) {
  const weights = [42, 26, 20, 12, 10, 8].slice(0, count)
  const free = total - count * floor,
    sum = weights.reduce((a, b) => a + b, 0)
  const result = weights.map((w) => floor + Math.floor((free * w) / sum))
  for (let i = 0; result.reduce((a, b) => a + b, 0) < total; i++) {
    result[i % count]++
  }
  return result
}
function Lock({ locked }: { locked: boolean }) {
  return (
    <svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
      <rect x="4.5" y="8.5" width="11" height="8" rx="2" stroke="currentColor" strokeWidth="1.6" />
      <path
        d={locked ? "M7 8.5V5.5a3 3 0 0 1 6 0v3" : "M7 8.5V5.5a3 3 0 0 1 6 0"}
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <path d="M10 12v1.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  )
}

function useFittedStage() {
  const viewport = React.useRef<HTMLDivElement>(null)
  const [scale, setScale] = React.useState(1)
  React.useLayoutEffect(() => {
    const element = viewport.current!
    const fit = () => setScale(Math.min(element.clientWidth / 600, element.clientHeight / 375))
    fit()
    const observer = new ResizeObserver(fit)
    observer.observe(element)
    return () => observer.disconnect()
  }, [])
  return { viewport, scale }
}

export default function Example({ active = true, config }: { active?: boolean; config?: Partial<Config> }) {
  const fitted = useFittedStage()
  const settings = { ...DEFAULT_CONFIG, ...config }
  const total = Math.round(clamp(settings.totalUnits, 20, 200)),
    count = Math.round(clamp(settings.segmentCount, 3, 6))
  const floor = Math.min(Math.round(clamp(settings.minimumUnits, 0, 10)), Math.floor(total / count))
  const [values, setValues] = React.useState(() => seed(total, count, floor))
  const [locks, setLocks] = React.useState<boolean[]>(() => Array.from({ length: count }, (_, i) => i === count - 1))
  const [keyboard, setKeyboard] = React.useState(true)
  const [editing, setEditing] = React.useState<number | null>(null)
  const before = React.useRef<number[] | null>(null)
  const cancelled = React.useRef(false)
  const cancel = React.useCallback(() => {
    if (before.current) {
      setValues(before.current)
      cancelled.current = true
    }
    before.current = null
    setEditing(null)
  }, [])
  React.useEffect(() => {
    before.current = null
    setValues(seed(total, count, floor))
    setLocks(Array.from({ length: count }, (_, i) => i === count - 1))
    setEditing(null)
  }, [total, count, floor])
  React.useEffect(() => {
    if (!active) {
      cancel()
    }
  }, [active, cancel])
  React.useEffect(() => {
    const hidden = () => {
      if (document.hidden) {
        cancel()
      }
    }
    window.addEventListener("blur", cancel)
    document.addEventListener("visibilitychange", hidden)
    return () => {
      window.removeEventListener("blur", cancel)
      document.removeEventListener("visibilitychange", hidden)
    }
  }, [cancel])
  const displayed = values.length === count ? values : seed(total, count, floor)
  const unlockedCount = locks.filter((locked) => !locked).length
  return (
    <div
      ref={fitted.viewport}
      style={{
        width: "100%",
        height: "100%",
        aspectRatio: "16 / 10",
        position: "relative",
        overflow: "hidden",
        background: "#eff0f1",
      }}
    >
      <div
        style={{
          width: 600,
          height: 375,
          position: "absolute",
          top: "50%",
          left: "50%",
          transform: `translate(-50%, -50%) scale(${fitted.scale})`,
          transformOrigin: "center",
        }}
      >
        <div
          className="budget-balance"
          data-total={total}
          data-keyboard={keyboard}
          data-active={active}
          data-dense={count > 4}
          data-values={displayed.join(",")}
          onKeyDownCapture={() => setKeyboard(true)}
          onPointerDownCapture={() => setKeyboard(false)}
          style={
            { "--settle": `${active && !keyboard ? clamp(settings.settleMs, 0, 240) : 0}ms` } as React.CSSProperties
          }
          onKeyDown={(e) => {
            if (e.key === "Escape") {
              e.preventDefault()
              cancel()
            }
          }}
        >
          <style>{`
.budget-balance{width:100%;height:100%;display:grid;place-items:center;color:#252c34;font-family:Arial,Helvetica,sans-serif;-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
.budget-balance *{box-sizing:border-box}.budget-balance button,.budget-balance input{font:inherit}
.budget-balance .bb-sheet{width:532px}
.budget-balance .bb-head{padding:0 0 18px}
.budget-balance .bb-total{display:flex;align-items:baseline;justify-content:space-between;font-size:23px;line-height:30px;letter-spacing:-.7px;font-weight:500}
.budget-balance .bb-total small{font:10px Arial,sans-serif;letter-spacing:.4px;color:#757e87}
.budget-balance .bb-beam{height:132px;position:relative;margin-top:14px;isolation:isolate}
.budget-balance .bb-piece{position:absolute;inset:0;width:calc(100% - var(--gaps));height:100%;transform-origin:left;border-radius:5px;background-image:linear-gradient(175deg,#ffffff18,transparent 55%,#00000008);box-shadow:inset 0 1px 0 #ffffff35,inset 0 -1px 0 #00000010,0 4px 7px -5px #18283b50;transition:transform var(--settle) cubic-bezier(.22,.68,0,1)}
.budget-balance .bb-piece-label{position:absolute;inset:0;width:calc(100% - var(--gaps));pointer-events:none;transition:transform var(--settle) cubic-bezier(.22,.68,0,1)}
.budget-balance .bb-piece-label span{position:absolute;left:0;top:50%;transform:translate(-50%,-50%);font-size:35px;line-height:1;letter-spacing:-1.7px;font-weight:400}
.budget-balance .bb-piece-label small{font-size:14px;letter-spacing:-.4px;margin-left:1px}
.budget-balance .bb-lines{display:grid;grid-template-columns:1fr 1fr;gap:4px 28px;padding:0}
.budget-balance .bb-line{display:grid;grid-template-columns:minmax(0,1fr) auto 26px;grid-template-rows:25px 27px;align-items:center;column-gap:8px;position:relative;min-width:0}
.budget-balance .bb-name{grid-column:1;grid-row:1;font-size:11px;display:flex;gap:6px;align-items:center;white-space:nowrap}
.budget-balance .bb-dot{width:5px;height:5px;border-radius:50%;flex:none;background:var(--line)}
.budget-balance .bb-rail{grid-column:1/-1;grid-row:2;height:44px;min-width:0;position:relative;display:flex;align-items:center}
.budget-balance .bb-track{position:absolute;left:8px;right:8px;height:3px;border-radius:3px;background:#d7dce1;pointer-events:none}
.budget-balance .bb-fill{position:absolute;inset:0;border-radius:3px;transform-origin:left;background:var(--line);transition:transform var(--settle) cubic-bezier(.22,.68,0,1)}
.budget-balance .bb-carriage{position:absolute;inset:0;transition:transform var(--settle) cubic-bezier(.22,.68,0,1)}
.budget-balance .bb-thumb{position:absolute;width:12px;height:12px;left:-6px;top:-4.5px;border-radius:50%;background:#fbfcfd;border:1px solid #0000000c;box-shadow:0 1px 2px #0000001a,0 2px 4px #0000000d;transition:transform 120ms ease-out}
.budget-balance .bb-range{appearance:none;position:absolute;inset:0;margin:0;width:100%;height:44px;opacity:0;cursor:ew-resize;touch-action:none}
.budget-balance .bb-range::-webkit-slider-runnable-track{height:3px}.budget-balance .bb-range::-webkit-slider-thumb{appearance:none;width:16px;height:23px;margin-top:-10px}
.budget-balance .bb-range::-moz-range-track{height:3px}.budget-balance .bb-range::-moz-range-thumb{width:16px;height:23px;border:0}
.budget-balance .bb-range:disabled{cursor:default}
.budget-balance .bb-rail:has(.bb-range:focus-visible){outline:2px solid #4056d8;outline-offset:-3px;border-radius:5px}
.budget-balance .bb-line[data-editing=true] .bb-fill,.budget-balance .bb-line[data-editing=true] .bb-carriage{transition:none}
.budget-balance .bb-line[data-editing=true] .bb-thumb{transform:scale(1.15);background:var(--line);border-color:#ffffff80}
.budget-balance .bb-value{grid-column:2;grid-row:1;position:relative;font-size:12px;letter-spacing:-.2px;text-align:right}
.budget-balance .bb-lock{grid-column:3;grid-row:1;display:grid;place-items:center;position:relative;width:26px;height:30px;border:0;background:transparent;padding:0;cursor:pointer;color:#a0a7ae;touch-action:manipulation;border-radius:5px;z-index:1}
.budget-balance .bb-lock:after{content:"";position:absolute;inset:-7px -8px}
.budget-balance .bb-lock svg{width:12px;height:12px;transition:transform 120ms ease-out}
.budget-balance .bb-lock[aria-pressed=true]{color:#35414c}
.budget-balance .bb-lock:active svg{transform:translateY(1px)}
.budget-balance .bb-lock:focus-visible{outline:2px solid #4056d8;outline-offset:0}
.budget-balance .bb-lock:disabled{cursor:default}
.budget-balance .bb-line[data-locked=true] .bb-track{background:repeating-linear-gradient(120deg,#c7ced5 0 2px,#e1e5e9 2px 5px)}
.budget-balance .bb-line[data-locked=true] .bb-thumb{background:#dce1e5;box-shadow:none}
.budget-balance[data-dense=true] .bb-beam{height:104px}
.budget-balance[data-keyboard=true] *,.budget-balance[data-active=false] *{transition:none!important}
@media(hover:hover) and (pointer:fine){.budget-balance .bb-lock:hover:not(:disabled){color:#4056d8}}
@media(prefers-reduced-motion:reduce){.budget-balance *{transition:none!important}}
          `}</style>
          <section className="bb-sheet" aria-label="Production budget">
            <header className="bb-head">
              <div className="bb-total">
                {money(total)}
                <small>USD</small>
              </div>
              <div
                className="bb-beam"
                aria-hidden="true"
                style={{ "--gaps": `${(count - 1) * 4}px` } as React.CSSProperties}
              >
                {displayed.map((value, i) => (
                  <div
                    key={LINES[i]}
                    className="bb-piece"
                    data-locked={Boolean(locks[i])}
                    style={{
                      transform: `translateX(calc(${
                        (displayed.slice(0, i).reduce((sum, amount) => sum + amount, 0) / total) * 100
                      }% + ${i * 4}px)) scaleX(${value / total})`,
                      backgroundColor: COLORS[i].beam,
                    }}
                  />
                ))}
                {displayed.map((value, i) => (
                  <div
                    key={`label-${LINES[i]}`}
                    className="bb-piece-label"
                    style={{
                      color: COLORS[i].text,
                      transform: `translateX(calc(${
                        ((displayed.slice(0, i).reduce((sum, amount) => sum + amount, 0) + value / 2) / total) * 100
                      }% + ${i * 4}px))`,
                      opacity: value / total >= 0.11 ? 1 : 0,
                    }}
                  >
                    <span>
                      {Math.round((value / total) * 100)}
                      <small>%</small>
                    </span>
                  </div>
                ))}
              </div>
            </header>
            <div className="bb-lines">
              {displayed.map((value, i) => {
                const max =
                  total -
                  displayed.reduce((sum, v, j) => sum + (j !== i && locks[j] ? v : 0), 0) -
                  floor * locks.filter((locked, j) => j !== i && !locked).length
                const disabled = !active || locks[i] || unlockedCount <= 1 || max <= floor
                const fraction = value / total
                return (
                  <div
                    className="bb-line"
                    key={LINES[i]}
                    data-locked={Boolean(locks[i])}
                    data-editing={editing === i}
                    style={{ "--line": COLORS[i].ink } as React.CSSProperties}
                  >
                    <span className="bb-name">
                      <i className="bb-dot" />
                      {LINES[i]}
                    </span>
                    <div className="bb-rail">
                      <div className="bb-track" aria-hidden="true">
                        <div className="bb-fill" style={{ transform: `scaleX(${fraction})` }} />
                        <div className="bb-carriage" style={{ transform: `translateX(${fraction * 100}%)` }}>
                          <span className="bb-thumb" />
                        </div>
                      </div>
                      <input
                        className="bb-range"
                        type="range"
                        min={0}
                        max={total}
                        aria-valuemin={floor}
                        aria-valuemax={max}
                        step="1"
                        value={value}
                        disabled={disabled}
                        aria-label={`${LINES[i]} allocation`}
                        aria-valuetext={`${value} thousand dollars${locks[i] ? ", locked" : ""}`}
                        onPointerDown={(event) => {
                          if (event.button !== 0) {
                            return
                          }
                          event.currentTarget.setPointerCapture(event.pointerId)
                          cancelled.current = false
                          before.current = [...displayed]
                          setEditing(i)
                        }}
                        onPointerUp={() => {
                          cancelled.current = false
                          before.current = null
                          setEditing(null)
                        }}
                        onPointerCancel={cancel}
                        onLostPointerCapture={() => {
                          if (before.current) {
                            cancel()
                          }
                        }}
                        onBlur={cancel}
                        onKeyDown={(event) => {
                          if (event.key !== "Escape") {
                            cancelled.current = false
                          }
                          setEditing(i)
                        }}
                        onKeyUp={() => setEditing(null)}
                        onChange={(event) => {
                          if (!active || cancelled.current) {
                            return
                          }
                          const next = Number(event.target.value)
                          setValues((current) => distribute(before.current ?? current, locks, i, next, floor))
                        }}
                      />
                    </div>
                    <output className="bb-value">
                      <span className="bb-price">{money(value)}</span>
                    </output>
                    <button
                      type="button"
                      className="bb-lock"
                      aria-label={`${locks[i] ? "Unlock" : "Lock"} ${LINES[i]}`}
                      aria-pressed={Boolean(locks[i])}
                      disabled={!active}
                      title={locks[i] ? `Unlock ${LINES[i]}` : `Keep ${LINES[i]} fixed`}
                      onClick={() => {
                        setLocks((current) => current.map((locked, j) => (j === i ? !locked : locked)))
                      }}
                    >
                      <Lock locked={Boolean(locks[i])} />
                    </button>
                  </div>
                )
              })}
            </div>
          </section>
        </div>
      </div>
    </div>
  )
}

Where it works well

Edit production budgets, capacity or category weights with a fixed total and protected allocations.

Reduced motion

Pointer edits track directly; keyboard, reduced-motion and inactive-host changes have no settle transition. Pausing cancels a pending transaction and disables controls.

Things to know

Local integer budget fixture in thousands of dollars. Changing line count, total or minimum resets allocations; an infeasible minimum is capped at floor(total / count). No persistence or financial service integration.

More examples