Percentage field
A percentage input that stores a ratio, a whole percent or basis points while people type the percent they see. Sized to its bounds, with the % sign, the allowed range and float-free conversion.
cmp_percentage_field_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_percentage_field_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-393e0e15ae635c771fb99a00842e4662be3352c87e6bf21b7d0db3fbd1c0ed97
This component needs all 2 files. Download the ZIP
/**
* Parsing, rounding and storage conversion for PercentageField. Pure functions, no DOM, so the
* same rules run on the server, in the browser and in your own code.
*
* Every conversion goes through a whole number of display steps ("units"): 12.5% with one
* fraction digit is 125 units. Converting between units and storage moves the decimal point in
* the number's text instead of multiplying floats, so 12.5% stores as exactly 0.125, 12.5 or
* 1250, never 0.12500000000000001.
*/
/** How a percentage is stored: 12.5% is 0.125 as a ratio, 12.5 as a percent, 1250 in basis points. */
export type PercentageStorage = 'ratio' | 'percent' | 'basis-points';
/** The result of reading what someone typed. */
export type PercentageParse =
{ kind: 'empty' } | { kind: 'invalid' } | { kind: 'number'; units: number };
/** Digits from scripts that write their own numerals, mapped to 0 to 9. */
const DIGIT_BLOCKS = [0x0660, 0x06f0, 0x0966, 0xff10];
/** Moves the decimal point of a finite number by `places` without floating-point arithmetic. */
export function shift(value: number, places: number): number {
const [mantissa, exponent = '0'] = String(value).toLowerCase().split('e');
return Number(`${mantissa}e${Number(exponent) + places}`);
}
/** Rounds half away from zero, so 12.25 at one digit is 12.3 and -12.25 is -12.3. */
function roundHalfAway(value: number): number {
return Math.sign(value) * Math.round(Math.abs(value));
}
/** Fraction digits clamped to the supported 0 to 2. */
export function clampDigits(fractionDigits: number): number {
return Math.min(2, Math.max(0, Math.trunc(fractionDigits) || 0));
}
/** A stored value as whole display units, or null when it is missing or not finite. */
export function storedToUnits(
value: number | null | undefined,
storage: PercentageStorage,
fractionDigits: number
): number | null {
if (value == null || !Number.isFinite(value)) return null;
const places = fractionDigits + (storage === 'ratio' ? 2 : storage === 'basis-points' ? -2 : 0);
const units = roundHalfAway(shift(value, places));
return Number.isSafeInteger(units) ? units : null;
}
/** Whole display units as the stored value, in the storage's own unit. */
export function unitsToStored(
units: number,
storage: PercentageStorage,
fractionDigits: number
): number {
const places = -fractionDigits - (storage === 'ratio' ? 2 : storage === 'basis-points' ? -2 : 0);
/* Basis points are whole by definition: 12.345% is 1235, not 1234.5. */
const stored = shift(units, places);
return storage === 'basis-points' ? roundHalfAway(stored) : stored;
}
/** A displayed percent (12.5) as whole display units, rounded to the field's digits. */
export function percentToUnits(percent: number, fractionDigits: number): number {
return roundHalfAway(shift(percent, fractionDigits));
}
/** Whole display units back to a displayed percent (125 units at one digit is 12.5). */
export function unitsToPercent(units: number, fractionDigits: number): number {
return shift(units, -fractionDigits);
}
/**
* Reads typed text as a displayed percent. Accepts a dot or a comma as the decimal mark in any
* locale (people type the one on their keyboard), the locale's own digits, a leading sign, and
* one percent sign before or after the number with optional space beside it. Rejects thousands
* separators, more than one decimal mark, spaces inside the number and anything else, rather
* than guessing: the other mark followed by exactly three digits (1,000 in English, 1.000 in
* German) reads as grouping, so it is refused. The result is rounded to `fractionDigits` in the
* text itself, half away from zero, so no float conversion ever decides the rounding.
*/
export function parsePercent(text: string, fractionDigits: number, locale = 'en'): PercentageParse {
let s = '';
for (const char of text) {
const code = char.codePointAt(0)!;
const block = DIGIT_BLOCKS.find((start) => code >= start && code <= start + 9);
s += block === undefined ? char : String(code - block);
}
s = s
.replace(/[\u200e\u200f\u061c]/g, '')
.replace(/\u2212/g, '-')
.trim()
/* One percent sign at either end, with any space between it and the number. */
.replace(/^[%\u066a\uff05]\s*|\s*[%\u066a\uff05]$/, '');
if (s === '') return { kind: 'empty' };
const match = /^([+-]?)(\d*)(?:[.,\u066b](\d*))?$/.exec(s);
if (!match || (match[2] === '' && !match[3])) return { kind: 'invalid' };
const [, sign, whole, fraction = ''] = match;
const mark = s.match(/[.,\u066b]/)?.[0];
if (mark && mark !== decimalMark(locale) && fraction.length === 3) return { kind: 'invalid' };
/* Keep fractionDigits digits, then carry one if the first dropped digit is 5 or more. */
const kept = `${whole}${fraction.slice(0, fractionDigits).padEnd(fractionDigits, '0')}`;
const carry = (fraction[fractionDigits] ?? '0') >= '5' ? 1 : 0;
const magnitude = Number(kept.replace(/^0+(?=\d)/, '') || '0') + carry;
if (!Number.isSafeInteger(magnitude)) return { kind: 'invalid' };
const units = sign === '-' ? -magnitude : magnitude;
return { kind: 'number', units: units === 0 ? 0 : units };
}
/** Where the locale puts the percent sign, the glyph it uses, and whether a space precedes it. */
export interface PercentSign {
symbol: string;
position: 'prefix' | 'suffix';
spaced: boolean;
}
/** Reads the percent sign's glyph, side and spacing from the locale (Turkish writes %12). */
export function percentSign(locale: string): PercentSign {
const parts = safeFormat(locale, { style: 'percent' }).formatToParts(0.5);
const signAt = parts.findIndex((part) => part.type === 'percentSign');
const numberAt = parts.findIndex((part) => part.type === 'integer');
if (signAt < 0) return { symbol: '%', position: 'suffix', spaced: false };
const position = signAt < numberAt ? 'prefix' : 'suffix';
const between = parts.slice(Math.min(signAt, numberAt) + 1, Math.max(signAt, numberAt));
const spaced = between.some((part) => /\s/.test(part.value));
return { symbol: parts[signAt].value.trim(), position, spaced };
}
/** Units as the text the field shows while not being edited: 12.5, or 12,5 in French. */
export function formatUnits(units: number, fractionDigits: number, locale: string): string {
return safeFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
useGrouping: false
}).format(unitsToPercent(units, fractionDigits));
}
/**
* The bounds as a range with the sign on both ends: 0%–100%, 0 %–100 % in French, %0–%100 in
* Turkish. Built from two formatted percents rather than Intl's formatRange, whose output and
* availability vary by engine, so the server and the browser always agree.
*/
export function formatRange(
min: number,
max: number,
fractionDigits: number,
locale: string
): string {
return `${formatPercent(min, fractionDigits, locale)}–${formatPercent(max, fractionDigits, locale)}`;
}
/** A single percent with its sign, for messages: 12.5%, or 12,5 % in French. */
export function formatPercent(percent: number, fractionDigits: number, locale: string): string {
return safeFormat(locale, {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits
}).format(shift(percent, -2));
}
/** The widest text the field has to hold, in characters, so it can be sized to fit. */
export function widestText(
min: number,
max: number,
fractionDigits: number,
locale: string
): number {
const wide = (n: number) =>
safeFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
useGrouping: false
}).format(n).length;
return Math.max(wide(min), wide(max));
}
/** The locale's own decimal mark: . in English, , in French and German. */
function decimalMark(locale: string): string {
const parts = safeFormat(locale, {}).formatToParts(1.5);
return parts.find((part) => part.type === 'decimal')?.value ?? '.';
}
/** Intl with a fallback to English when the locale tag is malformed. */
function safeFormat(locale: string, options: Intl.NumberFormatOptions): Intl.NumberFormat {
try {
return new Intl.NumberFormat(locale, { numberingSystem: 'latn', ...options });
} catch {
return new Intl.NumberFormat('en', { numberingSystem: 'latn', ...options });
}
}
Usage#
On this pageGive it a label, a storage convention and bounds in displayed percent, and bind value. People type 12.5; value becomes 0.125, 12.5 or 1250 depending on storage, and null while the field is empty, unreadable or out of range (bind status to know which). It does not apply the percentage to any amount, explain tax rules or clamp what someone typed.
- Suggested location
src/lib/components/percentage-field-01
Limitations
- min, max and step are in displayed percent whatever the storage: max={50} means 50%, not 0.5.
- value is null whenever the text is empty, unreadable or outside the bounds. The field shows what was typed and its own message, but never hands an out-of-range number back.
- Typed digits beyond
fractionDigitsround half away from zero (12.25 at one digit is 12.3). A value you pass with more precision, or out of range, is shown and flagged but not rewritten until someone edits it, the bounds change or the form resets. - A dot or a comma is read as the decimal mark in every locale. The locale's other mark followed by exactly three digits (1,000 in English) is refused as grouping rather than guessed.
- name submits the stored value through a hidden input, and only while the text is valid and in range; it follows the field's disabled and form attributes and updates only once the page has hydrated. The visible input submits nothing.
- locale defaults to 'en' so the server and browser render the same text. Pass the page's language; digits always display in Latin numerals, though Arabic-Indic and Persian digits are read.
fractionDigitsis 0 to 2. Basis points with fewer than two digits store whole basis points of the rounded percent.
Example
<script lang="ts">
import PercentageField from '$lib/components/percentage-field-01/PercentageField.svelte';
let discount = $state<number | null>(0.15);
</script>
<form method="POST" class="max-w-sm">
<PercentageField
label="Annual billing discount"
name="annual-discount"
storage="ratio"
max={50}
hint="Taken off the per-seat price when a team pays for the year up front."
bind:value={discount}
/>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Percentage field#
The storage contract#
People always type the percent they see. storage decides what value holds (shown with
fractionDigits={2}):
| Typed | ratio |
percent |
basis-points |
|---|---|---|---|
| 12.5 | 0.125 | 12.5 | 1250 |
| 100 | 1 | 100 | 10000 |
| 0.07 | 0.0007 | 0.07 | 7 |
min, max and step are always in displayed percent, whatever the storage.
value is null while the field is empty, unreadable or out of range. Bind status when you
need to know which:
<PercentageField label="Commission" storage="basis-points" bind:value bind:status />
{#if status === 'out-of-range'}…{/if}Why the numbers come back clean#
The field rounds what was typed to fractionDigits first, as a whole number of display steps
(12.5% at one digit is 125 steps), and then moves the decimal point in the number's text to
reach the storage unit. 0.07 / 100 in floating point is 0.0007000000000000001; the field
returns 0.0007. The same helpers are exported from percentage.ts if your own code needs to
convert stored values for display:
import { storedToUnits, formatUnits } from './percentage';
const units = storedToUnits(0.125, 'ratio', 1); // 125
formatUnits(units!, 1, 'fr-FR'); // "12,5"Locales#
locale decides the decimal mark shown, which side the sign sits on and whether a space
precedes it (12,5 % in French, %12,5 in Turkish), and the range text. A dot or a comma is
accepted as the decimal mark in every locale, because people type the one on their keyboard.
The locale's other mark followed by exactly three digits (1,000 in English, 1.000 in
German) is refused instead of being guessed at.
What it does not do#
It does not apply the percentage to any amount, explain tax rules, or clamp what someone typed.
A value outside the bounds is shown and flagged, and value stays null until it is fixed.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label, always shown. |
value | number | null | No | null | Bindable stored value in the storage unit: 0.125, 12.5 or 1250 for 12.5%. Null while the field is empty, unreadable or out of range. |
storage | 'ratio' | 'percent' | 'basis-points' | Yes | None | How value is stored: 'ratio' (0.125), 'percent' (12.5) or 'basis-points' (1250) for 12.5%. |
min | number | No | 0 | Lowest allowed percent, in displayed units. |
max | number | No | 100 | Highest allowed percent, in displayed units. Raise it for growth rates above 100%. |
fractionDigits | number | No | 0 | Decimals shown and kept, 0 to 2. Extra typed digits round half away from zero before conversion. |
step | number | No | 1 | Percent added or taken away by Arrow Up and Down; Shift steps ten times as far. Stepping stays within the bounds. |
locale | string | No | 'en' | BCP 47 tag deciding the decimal mark, the sign's side and spacing, and the range text. |
name | string | No | None | Form field name. A hidden input submits the stored value under it. |
hint | string | No | None | Help text under the label, first in aria-describedby. |
error | string | null | No | null | Validation message from your code. Takes the place of the field's own message and sets aria-invalid. |
required | boolean | No | false | Native required attribute on the visible input, with a visible Required marker. |
requiredLabel | string | No | 'Required' | Marker text for required fields. |
rangePrefix | string | No | 'Percentage, allowed range' | Visually hidden text read before the range, so the description names the unit and the bounds. |
invalidMessage | string | No | 'Enter the percentage as a number, such as {example}.' | Message for text that is not a number. {example} becomes a sample in the locale's format. |
rangeMessage | string | No | 'Enter a percentage from {min} to {max}.' | Message for a value outside the bounds. {min} and {max} become the formatted bounds. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before any message. |
size | 'sm' | 'md' | No | 'md' | 32 or 36 px tall, matching buttons of the same size, with the value at 13 or 16 px. Both are 44 px with 16 px text on touch screens. |
reserveError | boolean | No | true | Hold one line under the field for a message, so showing one moves nothing below. Turn it off in dense rows; a message longer than one line still grows the field. |
status | 'empty' | 'valid' | 'out-of-range' | 'invalid' | No | None | Bindable and read-only: whether the field holds a value and, when it does not, why. |
id | string | No | None | Input id. Defaults to one generated per instance; the hint, range and error ids are always generated. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange the copy, bounds and storage through props and retone the field through seven --percentage-field-* CSS variables, the same set as text-field-01. Hover, read-only and disabled fills are mixed from them.
- Storage: pick the unit your backend already uses. Ratio suits code that multiplies (price * (1 - discount)); percent suits forms that echo a rate; basis points suit money systems that keep integers.
- Bounds: min and max are in displayed percent. Raise max for growth targets; set min below zero for a change that can fall.
- Accent:
--percentage-field-accentcolours the focus edge and its halo. Keep it at 3:1 against the page. - Danger:
--percentage-field-dangercolours the error edge, focus ring, icon and message. The default isred-700(6.5:1 on white). - Neutrals:
--percentage-field-inkis the label and digits,--percentage-field-mutedthe hint, sign and range,--percentage-field-placeholderany placeholder,--percentage-field-hairlinethe resting edge (keep it at 3:1) and--percentage-field-surfacethe fill. - Worked retone for a
#09090bband:--percentage-field-accent:#fafafa;--percentage-field-ink:#fafafa;--percentage-field-muted:#a1a1aa;--percentage-field-placeholder:#a1a1aa;--percentage-field-hairline: rgb(255 255 255 / 0.35);--percentage-field-surface:#18181b;--percentage-field-danger:#f87171. Add color-scheme: dark on the band. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (it is alpha black) and set--percentage-field-surface:#fffdf9. - Width: the field is sized to the widest value its bounds allow, at least 96 px. Put it in a row with other short fields, or pass class for placement.
- Languages: pass locale with the page's language, and translate
requiredLabel,rangePrefix,invalidMessage,rangeMessageanderrorPrefix.
Public CSS variables
| Variable | Token |
|---|---|
--percentage-field-accent | accent |
--percentage-field-danger | danger |
--percentage-field-ink | ink |
--percentage-field-muted | muted |
--percentage-field-placeholder | placeholder |
--percentage-field-hairline | hairline |
--percentage-field-surface | surface |
Accessibility#
On this page- A native <label for> names the input. It is a text input with inputmode decimal, so phones show a number pad with a decimal key and nothing strips a comma.
aria-describedbylists the hint, then the range, then any message, then anyaria-describedbyyou pass. The range is read withrangePrefixfirst ('Percentage, allowed range 0%–50%'), so the unit and bounds are announced; the visible sign is hidden from assistive technology to avoid 'percent percent'.- Messages are never colour alone: an icon, text and a visually hidden
errorPrefix. They are not live regions; they appear when someone leaves the field, and your form should move focus or announce a summary when a submit fails. - While the text is unreadable or out of range, the input carries a custom validity message, so a native form submit is blocked unless the form sets novalidate.
- Arrow Up and Down change the value by step, within the bounds; the input keeps the textbox role, so this is a convenience rather than a promise to screen reader users.
- Focus draws a two-pixel edge in the accent plus a soft halo; while invalid the danger edge stays and the accent ring sits two pixels outside it. In forced-colours mode focus becomes a two-pixel Highlight outline.
- Inputs use 16 px text and grow to 44 px on touch screens, so iOS does not zoom and targets clear 44 px.
Known limitations
- The field's own messages are in English unless you pass
invalidMessageandrangeMessage.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.