ProductSet limit
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callSet 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add set-limitReact
npx designme add set-limit --reactThe 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.
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
- The bar shows
spentas a solid fill andforecastas a striped fill up to the cap. - Dragging along the bar snaps the cap to
step, and the knob glides to new values set any other way. - Clicking the big figure lets the person type a cap; Enter or leaving the field keeps it within
minandmax, and Escape restores the old value. - When the forecast passes the cap, the line warns "Forecast passes your cap around Oct 27" and says what runs will do; otherwise it shows the amount left.
- The At the limit switch sets
modeto pause, ask or keep, andmessagecan replace the line under the bar. formatcontrols how amounts are written, and the symbols around the number stay in place while typing.
- The knob is a
sliderwitharia-valuemin,aria-valuemax,aria-valuenowand a formattedaria-valuetext. - Arrow keys move the cap by
step, on the slider and in the typed figure. - The warning line is an
aria-live="polite"region, so going over or back on track is announced. - The At the limit switch is a labelled
groupwitharia-pressedon each choice.
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>;
}
Props
Everything SetLimit takes.
| Prop | Type | What it does |
|---|---|---|
value | number | The cap. Leave it out to let the slider set it |
defaultValue | number | |
onValueChange | (cap: number) => void | |
min | number | The lowest cap the slider allows |
max | number | The highest cap the slider allows |
step | number | How far one arrow key press or slider notch moves the cap |
spent | number | Spent so far this period, the solid part of the bar |
forecast | number | Where spending is headed by the end of the period, the striped part of the bar |
mode | SetLimitMode | What happens at the cap. Leave it out to let the switch set it |
defaultMode | SetLimitMode | |
onModeChange | (mode: SetLimitMode) => void | |
title | string | |
subtitle | string | The quiet line under the title, like "Resets Nov 1" |
passesOn | string | When the forecast passes the cap, like "Oct 27" |
format | (n: number) => string | Writes an amount, like "$200" |
editable | boolean | 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 |
message | (state: SetLimitMessage) => ReactNode | Replaces the line under the bar, which by default warns when the forecast passes the cap |
className | string |
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(); } }));
});
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
onValueChangeandonModeChangeand enforce them where your runs are billed. - How do I show amounts in another currency?
- Pass
format, liken => n + ' €'. The text around the number stays fixed while someone types a new cap. - Can I write my own warning text?
- Yes.
messagereceives whether the forecast is over, the cap, what is left and the mode, and returns what to show under the bar.
More in Product
Pricing, onboarding, setup flows and spending limits for the rest of your product.