Skip to content
Star on GitHub
ProductSet limit
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Set limit

A monthly agent budget with a draggable cap and a forecast. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add set-limit
Monthly agent budgetResets Nov 1
Spent $128Forecast $184
At the limit
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add set-limit

React

$npx designme add set-limit --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Set limit is a budget card for capping what an agent can spend in a month. A bar shows what has been spent so far and where spending is forecast to land, and the person drags a knob or types a figure to set the cap. A line under the bar warns when the forecast will pass the cap, and a switch decides whether runs pause, ask first or keep going at the limit.

  • When agent runs cost money and a team needs a monthly ceiling
  • When people should see whether their current pace will go over before it happens
  • When the team must choose what the agent does at the limit: stop, ask, or continue
  • When a cap needs to be adjusted quickly by dragging or typing an exact amount
03

Usage

Import it and drive it from your own state.

Import

import { SetLimit } from '@/components/designme/set-limit/SetLimit';

Example

The demo above as a React component, 23 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { SetLimit } from './SetLimit';
import { useStory } from '../lib/useScript';

/* ---------- Set limit: the cap eases down from $200 to $170, past the forecast, and the warning comes in, unless someone
   drags the cap or types a budget first ---------- */
export default function SetLimitDemo() {
  const [cap, setCap] = useState(200);
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait }) => {
    await wait(1500);
    const t0 = performance.now();
    for (let p = 0; p < 1;) {
      await wait(16);
      p = Math.min(1, (performance.now() - t0) / 900);
      setCap(Math.round((200 - 30 * (1 - Math.pow(1 - p, 3))) / 10) * 10);
    }
  });
  return <div ref={ref} style={{ width: '100%' }}><SetLimit value={cap} onValueChange={setCap} /></div>;
}
04

Props

Everything SetLimit takes.

PropTypeWhat it does
valuenumberThe cap. Leave it out to let the slider set it
defaultValuenumber
onValueChange(cap: number) => void
minnumberThe lowest cap the slider allows
maxnumberThe highest cap the slider allows
stepnumberHow far one arrow key press or slider notch moves the cap
spentnumberSpent so far this period, the solid part of the bar
forecastnumberWhere spending is headed by the end of the period, the striped part of the bar
modeSetLimitModeWhat happens at the cap. Leave it out to let the switch set it
defaultModeSetLimitMode
onModeChange(mode: SetLimitMode) => void
titlestring
subtitlestringThe quiet line under the title, like "Resets Nov 1"
passesOnstringWhen the forecast passes the cap, like "Oct 27"
format(n: number) => stringWrites an amount, like "$200"
editablebooleanLets the person click the big figure and type a cap (the default). Enter or leaving the field keeps it, held to min and max; Escape goes back
message(state: SetLimitMessage) => ReactNodeReplaces the line under the bar, which by default warns when the forecast passes the cap
classNamestring
05

Code

The full source, as the CLI installs it.

SetLimit.tsxReact · 150 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Seg } from '../lib/motion';
import '../designme.css';
import './set-limit.css';

/** What happens when spending reaches the cap */
export type SetLimitMode = 'pause' | 'ask' | 'keep';

export interface SetLimitMessage {
  /** True when the forecast goes past the cap */
  over: boolean;
  cap: number;
  /** The cap minus the forecast, what is left at the end of the period */
  left: number;
  mode: SetLimitMode;
}

export interface SetLimitProps {
  /** The cap. Leave it out to let the slider set it */
  value?: number;
  defaultValue?: number;
  onValueChange?: (cap: number) => void;
  /** The lowest cap the slider allows */
  min?: number;
  /** The highest cap the slider allows */
  max?: number;
  /** How far one arrow key press or slider notch moves the cap */
  step?: number;
  /** Spent so far this period, the solid part of the bar */
  spent?: number;
  /** Where spending is headed by the end of the period, the striped part of the bar */
  forecast?: number;
  /** What happens at the cap. Leave it out to let the switch set it */
  mode?: SetLimitMode;
  defaultMode?: SetLimitMode;
  onModeChange?: (mode: SetLimitMode) => void;
  title?: string;
  /** The quiet line under the title, like "Resets Nov 1" */
  subtitle?: string;
  /** When the forecast passes the cap, like "Oct 27" */
  passesOn?: string;
  /** Writes an amount, like "$200" */
  format?: (n: number) => string;
  /** Lets the person click the big figure and type a cap (the default). Enter or leaving the field keeps it, held to min and max; Escape goes back */
  editable?: boolean;
  /** Replaces the line under the bar, which by default warns when the forecast passes the cap */
  message?: (state: SetLimitMessage) => ReactNode;
  className?: string;
}

const MODES: { value: SetLimitMode; label: string }[] = [{ value: 'pause', label: 'Pause runs' }, { value: 'ask', label: 'Ask me' }, { value: 'keep', label: 'Keep going' }];
const WILL: Record<SetLimitMode, string> = { pause: 'pause', ask: 'wait for you', keep: 'keep going' };

/** A spending cap on a bar that shows what is spent and what is forecast, with what to do at the limit. */
export function SetLimit({ value, defaultValue = 200, onValueChange, min = 50, max = 500, step = 10, spent = 128, forecast = 184, mode, defaultMode = 'pause', onModeChange,
  title = 'Monthly agent budget', subtitle = 'Resets Nov 1', passesOn = 'Oct 27', format = n => '$' + n, editable = true, message, className }: SetLimitProps) {
  const [capRaw, setCap] = useControllable(value, defaultValue, onValueChange);
  const [atLimit, setMode] = useControllable(mode, defaultMode, onModeChange);
  const cap = Math.max(min, Math.min(max, capRaw));
  const [drag, setDrag] = useState(false);
  // a change from the keys or the field shows the amount over the knob for a moment, so it can be followed there too
  const [say, setSay] = useState(0);
  useEffect(() => { if (!say) return; const t = setTimeout(() => setSay(0), 1200); return () => clearTimeout(t); }, [say]);
  const told = () => setSay(n => n + 1);
  // the bars fill in a moment after the card appears
  const [ready, setReady] = useState(false);
  useEffect(() => { const t = setTimeout(() => setReady(true), 200); return () => clearTimeout(t); }, []);
  const bar = useRef<HTMLDivElement>(null);
  // the knob and bars glide to a new cap, except while it is being dragged
  const shown = useTween(cap, drag ? 1 : 450);
  // the figure being typed, while the field has focus
  const [draft, setDraft] = useState<string | null>(null);
  const before = useRef(cap), cancel = useRef(false);
  const input = useRef<HTMLInputElement>(null);
  const clamp = (n: number) => Math.max(min, Math.min(max, Math.round(n)));
  const commit = () => {
    if (draft === null) return;
    const n = cancel.current || draft === '' ? before.current : clamp(parseInt(draft, 10));
    setCap(n); if (n !== cap) told(); cancel.current = false; setDraft(null);
  };
  // the symbols around the number in `format`, like the "$" in "$200", stay put while the number is typed
  const text = format(cap), first = text.search(/\d/), last = text.length - [...text].reverse().join('').search(/\d/);
  const pre = first < 0 ? '' : text.slice(0, first), post = first < 0 ? '' : text.slice(last);

  const pct = (x: number) => (x - min) / (max - min) * 100;
  const snap = (x: number) => Math.max(min, Math.min(max, Math.round(x / step) * step));
  const at = (e: PointerEvent) => { const b = bar.current!.getBoundingClientRect(); setCap(snap(min + (max - min) * Math.max(0, Math.min(1, (e.clientX - b.left) / b.width)))); };
  const key = (e: KeyboardEvent) => {
    const dir = ({ ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 } as Record<string, number>)[e.key]; if (!dir) return;
    e.preventDefault(); setCap(Math.max(min, Math.min(max, cap + dir * step))); told();
  };

  const over = forecast > cap, left = cap - forecast;
  const used = ready ? Math.min(100, spent / shown * pct(shown)) : 0;
  const fc = ready ? Math.min(pct(shown), forecast / shown * pct(shown)) : 0;
  const figure = draft ?? String(Math.round(shown));
  const line = message ? message({ over, cap, left, mode: atLimit })
    : over ? <><Icon name="alert" size="xs" />Forecast passes your cap around {passesOn}. Runs will {WILL[atLimit]}.</>
      : <><Icon name="ok" size="xs" />On track. About {format(left)} left at month end.</>;

  return (
    <div data-dm="" className={cx('dm-slx dm-panel', className)}>
      <div className="dm-slx-h">
        <div><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
        {editable ? (
          // reads as the figure until it is clicked; a click selects it all, so typing replaces it
          <label className="dm-num dm-slx-v" onPointerDown={e => { if (document.activeElement !== input.current) { e.preventDefault(); input.current?.focus(); } }}>
            {pre}<input ref={input} className="dm-slx-in" inputMode="numeric" autoComplete="off" aria-label={title} value={figure} style={{ width: Math.max(1, figure.length) + 'ch' }}
              onFocus={e => { before.current = cap; setDraft(String(cap)); e.currentTarget.select(); }}
              onChange={e => { const v = e.target.value.replace(/\D/g, '').slice(0, 6); setDraft(v); const n = +v; if (v && n >= min && n <= max) { setCap(n); told(); } }}
              onKeyDown={e => {
                if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); }
                else if (e.key === 'Escape') { cancel.current = true; e.currentTarget.blur(); }
                else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); const n = clamp((parseInt(draft ?? '', 10) || cap) + (e.key === 'ArrowUp' ? step : -step)); setDraft(String(n)); setCap(n); told(); }
              }}
              onBlur={commit} />{post}
          </label>
        ) : <b className="dm-num dm-slx-v">{format(cap)}</b>}
      </div>
      <div ref={bar} className="dm-slx-bar"
        onPointerDown={e => { e.currentTarget.setPointerCapture(e.pointerId); setDrag(true); at(e); }}
        onPointerMove={e => { if (drag) at(e); }}
        onPointerUp={() => setDrag(false)} onPointerCancel={() => setDrag(false)}>
        <div className="dm-slx-used" style={{ width: used + '%' }} />
        <div className="dm-slx-fc" style={{ width: fc + '%' }} />
        <button type="button" className={cx('dm-slx-k', drag && 'dm-drag', say > 0 && 'dm-say')} role="slider" aria-label="Budget cap"
          aria-valuemin={min} aria-valuemax={max} aria-valuenow={cap} aria-valuetext={format(cap)} style={{ left: pct(shown) + '%' }} onKeyDown={key}>
          <span className="dm-slx-tt dm-num">{format(cap)}</span>
        </button>
      </div>
      <div className="dm-slx-lg dm-muted">
        <span><i style={{ background: 'var(--dm-accent)' }} />Spent <b className="dm-num">{format(spent)}</b></span>
        <span><i className="dm-fc" />Forecast <b className="dm-num">{format(forecast)}</b></span>
      </div>
      <div className="dm-slx-warn" aria-live="polite">
        {/* the line comes in again when it changes between on track and over, or the choice at the limit changes */}
        {ready && <span key={String(over) + atLimit} className={cx(over ? 'dm-w' : 'dm-o', 'dm-enter')}>{line}</span>}
      </div>
      <div className="dm-slx-opt">
        <span className="dm-muted">At the limit</span>
        <Seg className="dm-slx-sg" label="At the limit" value={atLimit} onChange={setMode} options={MODES} />
      </div>
    </div>
  );
}
set-limit.cssReact · 34 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Set limit ---------- */
.dm-slx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:14px}
.dm-slx-h{display:flex;align-items:flex-start;justify-content:space-between}
.dm-slx-h>div{display:flex;flex-direction:column;font-size:14px}
.dm-slx-h b{font-weight:500}
.dm-slx-h .dm-muted{font-size:12px}
.dm-slx-v{display:inline-flex;align-items:baseline;font-size:14px;font-weight:500!important;letter-spacing:-.02em;margin:-3px -7px 0 0;padding:1px 7px;border-radius:var(--dm-r-field);cursor:text;transition:background-color .2s var(--dm-ease),box-shadow .2s var(--dm-ease)}
/* the budget reads as a figure until you click it, then you can type a new one */
@media (hover:hover) and (pointer:fine){.dm-slx-v:hover{background:var(--dm-row)}}
.dm-slx-v:focus-within{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
@supports (corner-shape:squircle){.dm-slx-v{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-slx-in{width:3ch;min-width:1ch;padding:0;margin:0;border:0;background:none;font:inherit;font-variant-numeric:tabular-nums;letter-spacing:inherit;color:inherit;text-align:right;outline:none;caret-color:var(--dm-accent-fg,var(--dm-accent))}
.dm-slx-bar{position:relative;height:12px;border-radius:6px;background:var(--dm-chip);margin:18px 0 6px;touch-action:none;cursor:pointer}
.dm-slx-used{position:absolute;left:0;top:0;bottom:0;border-radius:6px;background:var(--dm-accent-mark,var(--dm-accent));transition:width .8s var(--dm-ease);z-index:1}
.dm-slx-fc{position:absolute;left:0;top:0;bottom:0;border-radius:6px;background:repeating-linear-gradient(-45deg,color-mix(in srgb,var(--dm-accent) 35%,transparent) 0 4px,color-mix(in srgb,var(--dm-accent) 15%,transparent) 4px 8px);transition:width .8s var(--dm-ease)}
.dm-slx-k{position:absolute;top:50%;width:6px;height:26px;margin:-13px 0 0 -3px;border-radius:var(--dm-r-pill);background:var(--dm-fg);z-index:2;cursor:grab;transition:transform .2s}
.dm-slx-k.dm-drag{box-shadow:0 0 0 4px var(--dm-accent-bg)}
@media (hover:hover) and (pointer:fine){.dm-slx-k:hover{box-shadow:0 0 0 4px var(--dm-accent-bg)}}
.dm-slx-tt{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:3px var(--dm-s-2);border-radius:var(--dm-r-pill);background:var(--dm-ink);color:var(--dm-on-ink);font-size:11px;white-space:nowrap;opacity:0;transition:opacity .2s;pointer-events:none}
/* the amount over the knob shows while it is dragged or hovered, and for a moment after the arrow keys or the field change it */
.dm-slx-k.dm-drag .dm-slx-tt,.dm-slx-k.dm-say .dm-slx-tt{opacity:1}
@media (hover:hover) and (pointer:fine){.dm-slx-k:hover .dm-slx-tt{opacity:1}}
.dm-slx-lg{display:flex;gap:var(--dm-s-4);font-size:12px}
.dm-slx-lg span{display:flex;align-items:center;gap:6px}
.dm-slx-lg b{color:var(--dm-fg);font-weight:500}
.dm-slx-lg i{width:8px;height:8px;border-radius:3px}
.dm-slx-lg i.dm-fc{background:color-mix(in srgb,var(--dm-accent) 35%,transparent)}
.dm-slx-warn{font-size:12.5px;min-height:18px}
.dm-slx-warn .dm-w{display:flex;align-items:center;gap:7px;color:var(--dm-warn)}
.dm-slx-warn .dm-o{display:flex;align-items:center;gap:7px;color:var(--dm-ok)}
.dm-slx-opt{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;font-size:12.5px}
.dm-slx-sg button{font-size:12px;height:28px;padding:0 10px}
set-limit.htmlHTML · 12 lines
<div class="card" data-c="set-limit">
  <div class="stage-in">
    <div class="slx panel">
      <div class="slx-h"><div><b>Monthly agent budget</b><span class="muted">Resets Nov 1</span></div><label class="num slx-v" data-tip="Type a budget"><span class="cur">$</span><input class="slx-in" inputmode="numeric" autocomplete="off" aria-label="Monthly budget, in dollars" value="200"></label></div>
      <div class="slx-bar"><div class="slx-used"></div><div class="slx-fc"></div><button class="slx-k" role="slider" aria-label="Budget cap" aria-valuemin="50" aria-valuemax="500"><span class="slx-tt num">$200</span></button></div>
      <div class="slx-lg muted"><span><i style="background:var(--accent-mark,var(--accent))"></i>Spent <b class="num slx-sp">$128</b></span><span><i class="fc"></i>Forecast <b class="num slx-fv">$184</b></span></div>
      <div class="slx-warn"></div>
      <div class="slx-opt"><span class="muted">At the limit</span><div class="seg2 slx-sg" role="group" aria-label="At the limit"><button aria-pressed="true" data-v="pause">Pause runs</button><button aria-pressed="false" data-v="ask">Ask me</button><button aria-pressed="false" data-v="keep">Keep going</button></div></div>
    </div>
  </div>
</div>
set-limit.cssHTML · 33 lines
/* ---------- Set limit ---------- */
.slx{padding:var(--s-4);display:flex;flex-direction:column;gap:14px}
.slx-h{display:flex;align-items:flex-start;justify-content:space-between}
.slx-h>div{display:flex;flex-direction:column;font-size:14px}
.slx-h b{font-weight:500}
.slx-h .muted{font-size:12px}
.slx-v{display:inline-flex;align-items:baseline;font-size:14px;font-weight:500!important;letter-spacing:-.02em;margin:-3px -7px 0 0;padding:1px 7px;border-radius:var(--r-field);cursor:text;transition:background-color .2s var(--ease),box-shadow .2s var(--ease)}
/* the budget reads as a figure until you click it, then you can type a new one */
@media (hover:hover) and (pointer:fine){.slx-v:hover{background:var(--row)}}
.slx-v:focus-within{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
@supports (corner-shape:squircle){.slx-v{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.slx-in{width:3ch;min-width:1ch;padding:0;margin:0;border:0;background:none;font:inherit;font-variant-numeric:tabular-nums;letter-spacing:inherit;color:inherit;text-align:right;outline:none;caret-color:var(--accent-fg,var(--accent))}
.slx-bar{position:relative;height:12px;border-radius:6px;background:var(--chip);margin:18px 0 6px;touch-action:none;cursor:pointer}
.slx-used{position:absolute;left:0;top:0;bottom:0;border-radius:6px;background:var(--accent-mark,var(--accent));transition:width .8s var(--ease);z-index:1}
.slx-fc{position:absolute;left:0;top:0;bottom:0;border-radius:6px;background:repeating-linear-gradient(-45deg,color-mix(in srgb,var(--accent) 35%,transparent) 0 4px,color-mix(in srgb,var(--accent) 15%,transparent) 4px 8px);transition:width .8s var(--ease)}
.slx-k{position:absolute;top:50%;width:6px;height:26px;margin:-13px 0 0 -3px;border-radius:var(--r-pill);background:var(--fg);z-index:2;cursor:grab;transition:transform .2s}
.slx-k.drag{box-shadow:0 0 0 4px var(--accent-bg)}
@media (hover:hover) and (pointer:fine){.slx-k:hover{box-shadow:0 0 0 4px var(--accent-bg)}}
.slx-tt{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:3px var(--s-2);border-radius:var(--r-pill);background:var(--ink);color:var(--on-ink);font-size:11px;white-space:nowrap;opacity:0;transition:opacity .2s;pointer-events:none}
/* the amount over the knob shows while it is dragged or hovered, and for a moment after the arrow keys or the field change it */
.slx-k.drag .slx-tt,.slx-k.say .slx-tt{opacity:1}
@media (hover:hover) and (pointer:fine){.slx-k:hover .slx-tt{opacity:1}}
.slx-lg{display:flex;gap:var(--s-4);font-size:12px}
.slx-lg span{display:flex;align-items:center;gap:6px}
.slx-lg b{color:var(--fg);font-weight:500}
.slx-lg i{width:8px;height:8px;border-radius:3px}
.slx-lg i.fc{background:color-mix(in srgb,var(--accent) 35%,transparent)}
.slx-warn{font-size:12.5px;min-height:18px}
.slx-warn .w{display:flex;align-items:center;gap:7px;color:var(--warn)}
.slx-warn .o{display:flex;align-items:center;gap:7px;color:var(--ok)}
.slx-opt{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;font-size:12.5px}
.slx-sg button{font-size:12px;height:28px;padding:0 10px}
set-limit.jsHTML · 45 lines
DM.def('set-limit', (r, c) => {
  const bar = $('.slx-bar', r), k = $('.slx-k', r), tt = $('.slx-tt', r), v = $('.slx-v', r), used = $('.slx-used', r), fc = $('.slx-fc', r), warn = $('.slx-warn', r);
  const MIN = 50, MAX = 500, SPENT = 128, FC = 184; let cap = 200, mode = 'pause';
  const pct = x => (x - MIN) / (MAX - MIN) * 100;
  const vin = $('.slx-in', r), fit = () => { vin.style.width = Math.max(1, vin.value.length) + 'ch'; };
  function upd(anim){
    k.style.left = pct(cap) + '%'; tt.textContent = '$' + cap; k.setAttribute('aria-valuenow', cap);
    // while someone types, the field keeps what they typed
    if (document.activeElement !== vin){ vin.value = cap; fit(); }
    used.style.width = Math.min(100, SPENT / cap * pct(cap)) + '%'; fc.style.width = Math.min(pct(cap), FC / cap * pct(cap)) + '%';
    const over = FC > cap;
    const h = over ? `<span class="w">${I('alert','xs')}Forecast passes your cap around Oct 27. Runs will ${mode === 'pause' ? 'pause' : mode === 'ask' ? 'wait for you' : 'keep going'}.</span>` : `<span class="o">${I('ok','xs')}<span>On track. About <span class="slx-left">$${cap - FC}</span> left at month end.</span></span>`;
    // a new message comes in with the entrance; while it stays the same, only the amount left follows the cap
    if (warn.dataset.h !== String(over) + mode){ warn.dataset.h = String(over) + mode; warn.innerHTML = h; enter(warn.firstChild); }
    else if (!over) $('.slx-left', warn).textContent = '$' + (cap - FC);
  }
  const at = e => { glide++; const b = bar.getBoundingClientRect(); cap = Math.round((MIN + (MAX - MIN) * Math.max(0, Math.min(1, (e.clientX - b.left) / b.width))) / 10) * 10; upd(); };
  bar.onpointerdown = e => { bar.setPointerCapture(e.pointerId); k.classList.add('drag'); at(e); bar.onpointermove = at; };
  bar.onpointerup = bar.onpointercancel = () => { k.classList.remove('drag'); bar.onpointermove = null; };
  // a change from the keys or the field shows the amount over the knob for a moment, so it can be followed there too
  let sayT = 0; const say = () => { k.classList.add('say'); clearTimeout(sayT); sayT = setTimeout(() => k.classList.remove('say'), 1200); };
  k.onkeydown = e => { if (e.key === 'ArrowRight' || e.key === 'ArrowUp'){ cap = Math.min(MAX, cap + 10); upd(); say(); e.preventDefault(); } if (e.key === 'ArrowLeft' || e.key === 'ArrowDown'){ cap = Math.max(MIN, cap - 10); upd(); say(); e.preventDefault(); } };
  seg($('.slx-sg', r), m => { mode = m; upd(); });
  // typing a budget: the cap glides to it while it's in range; Enter or leaving the field keeps it (held to the range), Escape goes back
  let glide = 0, before = cap;
  const goTo = n => { const id = ++glide, from = cap; say(); tween(from, n, 450, x => { if (id === glide){ cap = Math.round(x); upd(); } }); };
  const commit = () => { const n = vin.value === '' ? before : Math.max(MIN, Math.min(MAX, parseInt(vin.value, 10))); vin.value = n; fit(); if (n !== cap) goTo(n); };
  // a click selects the whole figure at once, so typing replaces it
  v.addEventListener('pointerdown', e => { if (document.activeElement !== vin){ e.preventDefault(); vin.focus({ preventScroll:true }); } });
  vin.onfocus = () => { before = cap; vin.select(); };
  vin.oninput = () => { vin.value = vin.value.replace(/\D/g, '').slice(0, 4); fit(); const n = +vin.value; if (vin.value && n >= MIN && n <= MAX) goTo(n); };
  vin.onkeydown = e => {
    if (e.key === 'Enter'){ e.preventDefault(); vin.blur(); }
    else if (e.key === 'Escape'){ vin.value = before; goTo(before); vin.blur(); }
    else if (e.key === 'ArrowUp' || e.key === 'ArrowDown'){ e.preventDefault(); const n = Math.max(MIN, Math.min(MAX, (parseInt(vin.value, 10) || cap) + (e.key === 'ArrowUp' ? 10 : -10))); vin.value = n; fit(); goTo(n); }
  };
  vin.onblur = commit;
  used.style.width = 0; fc.style.width = 0; k.style.left = pct(cap) + '%'; fit();
  // the line under the bar takes one line or two, so the panel stays centred in the room its stage keeps for it
  const root = $('.slx', r); centre(c, root, () => root.offsetHeight);
  c.later(200, () => upd());
  // the preview lowers the cap once on its own, unless you take over first
  c.auto(1500, () => tween(200, 170, 900, x => { if (!c.mine){ cap = Math.round(x / 10) * 10; upd(); } }));
});
06

Questions

What people ask about it.

Does this component stop the agent when the cap is reached?
No. It shows and sets the cap and the at-the-limit choice. Read them from onValueChange and onModeChange and enforce them where your runs are billed.
How do I show amounts in another currency?
Pass format, like n => n + ' €'. The text around the number stays fixed while someone types a new cap.
Can I write my own warning text?
Yes. message receives whether the forecast is over, the cap, what is left and the mode, and returns what to show under the bar.
07

More in Product

Pricing, onboarding, setup flows and spending limits for the rest of your product.

Pricing cardsThree plans with a billing switch and a seat slider.
Onboarding checklistFirst steps with a progress ring, and the agent can do some for you.
StepperA multi-step setup with a progress track that fills as you go.
Currency converterShow plan prices in local currency, with a swap and live rate.