Auto Reload
Toggle + collapsible threshold/amount form for automatic credit reloading with locale-aware currency. Install Auto Reload from the VLLNT UI registry with the shadcn CLI.
Toggle + collapsible threshold/amount form for automatic credit reloading with locale-aware currency. Part of the billing family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Auto Reload to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/auto-reload.jsonSource
"use client";
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type ReactNode,
useCallback,
useId,
useMemo,
useState,
} from "react";
import { cn } from "../../lib/utils";
import { Button } from "../button/button";
import { Input } from "../input/input";
import { Switch } from "../switch/switch";
const CENTS_PER_UNIT = 100;
const DEFAULT_LOCALE = "en-US";
const DEFAULT_CURRENCY = "USD";
const DEFAULT_STEP_CENTS = 100;
/**
* Snapshot passed to {@link AutoReloadProps.onSave}.
*
* @public
*/
export type AutoReloadSavePayload = {
reloadAmountCents: number;
thresholdCents: number;
};
/**
* Localizable strings.
*
* @public
*/
export type AutoReloadLabels = {
/** Caption for the disabled banner. */
disabledFallback?: string;
/** Caption for the toggle headline. Defaults to `"Auto-reload"`. */
heading?: string;
/** Helper line under the toggle headline. Defaults to `"Automatically reload credits when balance is low."`. */
helper?: string;
/** Helper line under the reload-amount input. */
reloadHelper?: string;
/** Caption for the reload-amount input. Defaults to `"Reload amount"`. */
reloadLabel?: string;
/** Caption for the save button. Defaults to `"Save settings"`. */
save?: string;
/** Caption for the save button while saving. Defaults to `"Saving…"`. */
saving?: string;
/** Helper line under the threshold input. */
thresholdHelper?: string;
/** Caption for the threshold input. Defaults to `"Threshold"`. */
thresholdLabel?: string;
};
const DEFAULT_LABELS = {
disabledFallback: "Auto-reload is unavailable for this account.",
heading: "Auto-reload",
helper: "Automatically reload credits when balance is low.",
reloadHelper: "Amount to add when the threshold is hit.",
reloadLabel: "Reload amount",
save: "Save settings",
saving: "Saving…",
thresholdHelper: "Reload when the balance drops below this.",
thresholdLabel: "Threshold",
} as const satisfies Required<AutoReloadLabels>;
/**
* Props for {@link AutoReload}.
*
* @public
*/
export type AutoReloadProps = {
/** Currency code (ISO 4217). Defaults to `"USD"`. */
currency?: string;
/** Override the symbol displayed inside the inputs (e.g. `"€"`). */
currencySymbol?: string;
/** Initial enabled state when uncontrolled. */
defaultEnabled?: boolean;
/** Initial reload-amount value (cents) when uncontrolled. */
defaultReloadAmountCents?: number;
/** Initial threshold value (cents) when uncontrolled. */
defaultThresholdCents?: number;
/** When true, the consumer cannot interact with the control. */
disabled?: boolean;
/** Caption rendered with `disabled`. */
disabledMessage?: ReactNode;
/** Controlled enabled state. */
enabled?: boolean;
/** When true, the save button renders as loading + disabled. */
isSaving?: boolean;
/** Localizable strings. */
labels?: AutoReloadLabels;
/** BCP-47 locale tag. Defaults to `"en-US"`. */
locale?: string;
/** Highest allowed amount (cents). */
maxAmountCents?: number;
/** Lowest allowed amount (cents). Defaults to `100`. */
minAmountCents?: number;
/** Fires when the reload-amount input changes (cents). Required to keep `reloadAmountCents` controlled. */
onReloadAmountChange?: (cents: number) => void;
/** Fires when the user clicks save. */
onSave?: (payload: AutoReloadSavePayload) => void;
/** Fires when the threshold input changes (cents). Required to keep `thresholdCents` controlled. */
onThresholdChange?: (cents: number) => void;
/** Fires when the toggle changes. */
onToggle?: (enabled: boolean) => void;
/** Controlled reload-amount value (cents). */
reloadAmountCents?: number;
/** Step granularity for the inputs (cents). Defaults to `100`. */
stepCents?: number;
/** Controlled threshold value (cents). */
thresholdCents?: number;
} & ComponentPropsWithoutRef<"div">;
function centsToValue(cents: number): string {
return (cents / CENTS_PER_UNIT).toFixed(2);
}
function valueToCents(value: string): number {
const parsed = Number.parseFloat(value);
if (Number.isNaN(parsed)) return 0;
return Math.round(parsed * CENTS_PER_UNIT);
}
const CURRENCY_FORMATTER_CACHE = new Map<string, Intl.NumberFormat>();
function getCurrencyFormatter(
locale: string,
currency: string,
): Intl.NumberFormat {
const key = `${locale}|${currency}`;
let formatter = CURRENCY_FORMATTER_CACHE.get(key);
if (!formatter) {
formatter = Intl.NumberFormat(locale, {
currency,
style: "currency",
});
CURRENCY_FORMATTER_CACHE.set(key, formatter);
}
return formatter;
}
function getCurrencySymbol(locale: string, currency: string): string {
const formatted = getCurrencyFormatter(locale, currency).format(0);
const symbol = formatted.replaceAll(/[\d\s,.]/g, "");
return symbol.length > 0 ? symbol : currency;
}
type ReloadFormProps = {
currencyDisplay: string;
isSaving: boolean;
labels: Required<AutoReloadLabels>;
maxAmountCents?: number;
minAmountCents: number;
onSave: () => void;
reloadAmount: number;
reloadAmountId: string;
setReloadAmount: (value: number) => void;
setThreshold: (value: number) => void;
stepCents: number;
threshold: number;
thresholdId: string;
};
function ReloadFormFields({
currencyDisplay,
isSaving,
labels,
maxAmountCents,
minAmountCents,
onSave,
reloadAmount,
reloadAmountId,
setReloadAmount,
setThreshold,
stepCents,
threshold,
thresholdId,
}: ReloadFormProps): ReactNode {
const handleThreshold = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setThreshold(valueToCents(event.target.value));
},
[setThreshold],
);
const handleReload = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setReloadAmount(valueToCents(event.target.value));
},
[setReloadAmount],
);
const step = (stepCents / CENTS_PER_UNIT).toFixed(2);
const min = (minAmountCents / CENTS_PER_UNIT).toFixed(2);
const max =
maxAmountCents === undefined
? undefined
: (maxAmountCents / CENTS_PER_UNIT).toFixed(2);
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<NumericField
currencyDisplay={currencyDisplay}
helper={labels.thresholdHelper}
id={thresholdId}
label={labels.thresholdLabel}
max={max}
min={min}
onChange={handleThreshold}
step={step}
value={centsToValue(threshold)}
/>
<NumericField
currencyDisplay={currencyDisplay}
helper={labels.reloadHelper}
id={reloadAmountId}
label={labels.reloadLabel}
max={max}
min={min}
onChange={handleReload}
step={step}
value={centsToValue(reloadAmount)}
/>
</div>
<div>
<Button disabled={isSaving} onClick={onSave} type="button">
{isSaving ? labels.saving : labels.save}
</Button>
</div>
</div>
);
}
type NumericFieldProps = {
currencyDisplay: string;
helper?: string;
id: string;
label: string;
max?: string;
min?: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
step: string;
value: string;
};
function NumericField({
currencyDisplay,
helper,
id,
label,
max,
min,
onChange,
step,
value,
}: NumericFieldProps): ReactNode {
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground" htmlFor={id}>
{label} ({currencyDisplay})
</label>
<Input
id={id}
inputMode="decimal"
max={max}
min={min}
onChange={onChange}
step={step}
type="number"
value={value}
/>
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
type ToggleHeaderProps = {
enabled: boolean;
heading: string;
helper: string;
id: string;
onCheckedChange: (next: boolean) => void;
};
function ToggleHeader({
enabled,
heading,
helper,
id,
onCheckedChange,
}: ToggleHeaderProps): ReactNode {
const helperId = `${id}-helper`;
return (
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-foreground" id={id}>
{heading}
</span>
<span className="text-xs text-muted-foreground" id={helperId}>
{helper}
</span>
</div>
<Switch
aria-describedby={helperId}
aria-labelledby={id}
checked={enabled}
onCheckedChange={onCheckedChange}
/>
</header>
);
}
type ControllerOptions = {
defaultEnabled: boolean;
defaultReloadAmountCents: number;
defaultThresholdCents: number;
enabled?: boolean;
onReloadAmountChange?: (cents: number) => void;
onThresholdChange?: (cents: number) => void;
onToggle?: (enabled: boolean) => void;
reloadAmountCents?: number;
thresholdCents?: number;
};
type ControllerState = {
enabled: boolean;
handleToggle: (next: boolean) => void;
reloadAmount: number;
setReloadAmount: (value: number) => void;
setThreshold: (value: number) => void;
threshold: number;
};
function useAutoReloadController(options: ControllerOptions): ControllerState {
const {
defaultEnabled,
defaultReloadAmountCents,
defaultThresholdCents,
enabled: controlledEnabled,
onReloadAmountChange,
onThresholdChange,
onToggle,
reloadAmountCents: controlledReload,
thresholdCents: controlledThreshold,
} = options;
const [uncontrolledEnabled, setUncontrolledEnabled] =
useState(defaultEnabled);
const [uncontrolledThreshold, setUncontrolledThreshold] = useState(
defaultThresholdCents,
);
const [uncontrolledReloadAmount, setUncontrolledReloadAmount] = useState(
defaultReloadAmountCents,
);
const enabled = controlledEnabled ?? uncontrolledEnabled;
const handleToggle = useCallback(
(next: boolean) => {
if (controlledEnabled === undefined) setUncontrolledEnabled(next);
onToggle?.(next);
},
[controlledEnabled, onToggle],
);
const setReloadAmount = useCallback(
(next: number) => {
if (controlledReload === undefined) setUncontrolledReloadAmount(next);
onReloadAmountChange?.(next);
},
[controlledReload, onReloadAmountChange],
);
const setThreshold = useCallback(
(next: number) => {
if (controlledThreshold === undefined) setUncontrolledThreshold(next);
onThresholdChange?.(next);
},
[controlledThreshold, onThresholdChange],
);
return {
enabled,
handleToggle,
reloadAmount: controlledReload ?? uncontrolledReloadAmount,
setReloadAmount,
setThreshold,
threshold: controlledThreshold ?? uncontrolledThreshold,
};
}
/**
* Toggle + collapsible configuration form for automatic credit reloading.
* Composes {@link Switch}, {@link Input}, and {@link Button}. Renders a
* disabled banner when the consumer passes `disabled` so the control can
* advertise itself before the user has access (e.g. before subscribing).
*
* Values flow through the component in **minor units** (cents). Inputs
* display the corresponding currency unit; consumers receive the cents
* value from `onSave`.
*
* When you pass the controlled `reloadAmountCents` / `thresholdCents` props
* you must also pass `onReloadAmountChange` / `onThresholdChange` and feed the
* value back, otherwise the component ignores user edits. Omit the controlled
* props to run uncontrolled with `defaultReloadAmountCents` /
* `defaultThresholdCents`.
*
* @example
* ```tsx
* <AutoReload
* enabled={settings.autoReloadEnabled}
* thresholdCents={settings.thresholdCents}
* reloadAmountCents={settings.reloadAmountCents}
* currency="EUR"
* onToggle={handleToggle}
* onThresholdChange={setThresholdCents}
* onReloadAmountChange={setReloadAmountCents}
* onSave={handleSave}
* isSaving={isSaving}
* />
* ```
*
* @public
*/
type DisabledBannerProps = {
className?: string;
message: ReactNode;
} & ComponentPropsWithoutRef<"div">;
const DisabledBanner = ({
className,
message,
ref,
...rest
}: DisabledBannerProps & { ref?: React.Ref<HTMLDivElement> }) => (
<div
aria-disabled="true"
className={cn(
"flex items-start gap-3 rounded-2xl border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground",
className,
)}
ref={ref}
{...rest}
>
{message}
</div>
);
DisabledBanner.displayName = "AutoReload.DisabledBanner";
type ActivePanelProps = {
className?: string;
controller: ControllerState;
currencyDisplay: string;
isSaving: boolean;
labels: Required<AutoReloadLabels>;
maxAmountCents?: number;
minAmountCents: number;
onSave: () => void;
reloadAmountId: string;
stepCents: number;
thresholdId: string;
toggleId: string;
} & ComponentPropsWithoutRef<"div">;
const ActivePanel = ({
ref,
...props
}: ActivePanelProps & { ref?: React.Ref<HTMLDivElement> }) => {
const {
className,
controller,
currencyDisplay,
isSaving,
labels,
maxAmountCents,
minAmountCents,
onSave,
reloadAmountId,
stepCents,
thresholdId,
toggleId,
...rest
} = props;
return (
<div
className={cn(
"flex flex-col gap-4 rounded-2xl border bg-background p-4",
className,
)}
ref={ref}
{...rest}
>
<ToggleHeader
enabled={controller.enabled}
heading={labels.heading}
helper={labels.helper}
id={toggleId}
onCheckedChange={controller.handleToggle}
/>
{controller.enabled ? (
<ReloadFormFields
currencyDisplay={currencyDisplay}
isSaving={isSaving}
labels={labels}
maxAmountCents={maxAmountCents}
minAmountCents={minAmountCents}
onSave={onSave}
reloadAmount={controller.reloadAmount}
reloadAmountId={reloadAmountId}
setReloadAmount={controller.setReloadAmount}
setThreshold={controller.setThreshold}
stepCents={stepCents}
threshold={controller.threshold}
thresholdId={thresholdId}
/>
) : null}
</div>
);
};
ActivePanel.displayName = "AutoReload.ActivePanel";
type AutoReloadInternalState = {
controller: ControllerState;
currencyDisplay: string;
handleSave: () => void;
reloadAmountId: string;
resolvedLabels: Required<AutoReloadLabels>;
thresholdId: string;
toggleId: string;
};
function useAutoReloadInternals(
props: AutoReloadProps,
): AutoReloadInternalState {
const {
currency = DEFAULT_CURRENCY,
currencySymbol,
defaultEnabled = false,
defaultReloadAmountCents = 2000,
defaultThresholdCents = 1000,
enabled: controlledEnabled,
labels,
locale = DEFAULT_LOCALE,
onReloadAmountChange,
onSave,
onThresholdChange,
onToggle,
reloadAmountCents: controlledReload,
thresholdCents: controlledThreshold,
} = props;
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const toggleId = useId();
const thresholdId = useId();
const reloadAmountId = useId();
const controller = useAutoReloadController({
defaultEnabled,
defaultReloadAmountCents,
defaultThresholdCents,
enabled: controlledEnabled,
onReloadAmountChange,
onThresholdChange,
onToggle,
reloadAmountCents: controlledReload,
thresholdCents: controlledThreshold,
});
const currencyDisplay = currencySymbol ?? getCurrencySymbol(locale, currency);
const handleSave = useCallback(() => {
onSave?.({
reloadAmountCents: controller.reloadAmount,
thresholdCents: controller.threshold,
});
}, [controller.reloadAmount, controller.threshold, onSave]);
return {
controller,
currencyDisplay,
handleSave,
reloadAmountId,
resolvedLabels,
thresholdId,
toggleId,
};
}
export const AutoReload = ({
ref,
...props
}: AutoReloadProps & { ref?: React.Ref<HTMLDivElement> }) => {
const {
className,
disabled = false,
disabledMessage,
isSaving = false,
maxAmountCents,
minAmountCents = 100,
stepCents = DEFAULT_STEP_CENTS,
} = props;
const internals = useAutoReloadInternals(props);
if (disabled) {
return (
<DisabledBanner
className={className}
message={disabledMessage ?? internals.resolvedLabels.disabledFallback}
ref={ref}
/>
);
}
return (
<ActivePanel
className={className}
controller={internals.controller}
currencyDisplay={internals.currencyDisplay}
isSaving={isSaving}
labels={internals.resolvedLabels}
maxAmountCents={maxAmountCents}
minAmountCents={minAmountCents}
onSave={internals.handleSave}
ref={ref}
reloadAmountId={internals.reloadAmountId}
stepCents={stepCents}
thresholdId={internals.thresholdId}
toggleId={internals.toggleId}
/>
);
};
AutoReload.displayName = "AutoReload";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.