Percent Input

Catches 0.15 typed for 15%, basis-point slips and rates over the cap, and reads the result back in money, so a rate isn't 100 times off.

StopsRate entered 100 times too small

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/percent-input.json

The files land in your project. They're yours to edit.

Usage

percent-input-usage.tsx
import { PercentInput } from "@/components/ui/percent-input"

<PercentInput label="Tip" defaultValue={0.15} base={2000} currency="INR" />

What it catches

  • Fraction typed instead of percent (0.15 for 15%)
  • Tiny basis-point values (0.25 bps for 25)
  • Over max or under min, with a dropped-zero fix (150 → 15)
  • Money readback against a base amount

Props

PercentInput props
PropTypeDefault
label

Visible label.

string"Percentage"
value

Controlled value, in `valueFormat`. `null` means empty.

number | null—
defaultValue

Initial value when uncontrolled.

number | nullnull
onValueChange

Called with the value in `valueFormat`, or `null` when empty or unreadable.

(value: number | null) => void—
valueFormat

How values go in and out: `percent` (15 means 15%) or `fraction` (0.15 means 15%).

"percent" | "fraction""percent"
min

Lowest allowed percent.

number0
max

Highest allowed percent. Set higher for growth rates or markups.

number100
likelyMin

Positive values below this look like a decimal typed for a percent (0.15 for 15%). 0 turns it off.

number1
basisPoints

Show a % / bps switch. Switching converts the typed number, so the amount never changes meaning.

booleanfalse
defaultUnit

Unit the field starts in when `basisPoints` is on.

PercentUnit"percent"
base

Amount the percent applies to, for the readback ("15% of ₹2,000 = ₹300").

number—
currency

ISO 4217 currency of `base`. Leave out for a plain number.

string—
locale

BCP 47 locale for number formatting.

string"en-IN"
fractionMessage

Message when a fraction was typed for a percent.

(typed: string, fix: string) => string(typed, fix) => `${typed} is under 1%. Did you mean ${fix}?`
tinyBpsMessage

Message when a tiny basis-point value was typed.

(typed: string, fix: string) => string(typed, fix) => `${typed} is a tiny fraction of a percent…
overMessage

Message when the value is above max.

(max: string) => string(limit) => `Can't be more than ${limit}.`
underMessage

Message when the value is below min.

(min: string) => string(limit) => `Can't be less than ${limit}.`
fixLabel

Fix button text; receives the fixed value with its unit, e.g. "15%".

(fix: string) => string(fix) => `Use ${fix}`
decimalText

Readback without a base; receives the percent and the same value as a decimal.

(percent: string, decimal: string) => string(percent, decimal) => `${percent} = ${decimal} as a decimal`

* required. Also accepts the native props of <input> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Visible label; input has aria-invalid on error and is described by a polite status
  • Errors use role=alert
  • Unit switch is a labelled group of aria-pressed buttons

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

  • "Likely" ranges are heuristics; set likelyMin, min and max for your domain
  • Locale defaults to en-IN; pass locale and currency for other markets

What it stops

Rate entered 100 times too small

Also known as

percent · percentage · rate · discount · interest rate · commission · gst rate · bps

Field type

Dependencies

Pairs well with