Skip to content
Download ZIP

Blue accent palette · 13.1 KB ZIP File receipt View as Markdown View code

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.
cmp_percentage_field_01 · version 1.0.0 · Blue accent palette
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

percentage.ts TypeScript · 8.1 KB Raw
/**
 * 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 });
	}
}

Give 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
Required props
labelstorage

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 fractionDigits round 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.
  • fractionDigits is 0 to 2. Basis points with fewer than two digits store whole basis points of the rounded percent.

Example

Svelte
<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:

Svelte
<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:

TypeScript
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
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label, always shown.
valuenumber | nullNonullBindable 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'YesNoneHow value is stored: 'ratio' (0.125), 'percent' (12.5) or 'basis-points' (1250) for 12.5%.
minnumberNo0Lowest allowed percent, in displayed units.
maxnumberNo100Highest allowed percent, in displayed units. Raise it for growth rates above 100%.
fractionDigitsnumberNo0Decimals shown and kept, 0 to 2. Extra typed digits round half away from zero before conversion.
stepnumberNo1Percent added or taken away by Arrow Up and Down; Shift steps ten times as far. Stepping stays within the bounds.
localestringNo'en'BCP 47 tag deciding the decimal mark, the sign's side and spacing, and the range text.
namestringNoNoneForm field name. A hidden input submits the stored value under it.
hintstringNoNoneHelp text under the label, first in aria-describedby.
errorstring | nullNonullValidation message from your code. Takes the place of the field's own message and sets aria-invalid.
requiredbooleanNofalseNative required attribute on the visible input, with a visible Required marker.
requiredLabelstringNo'Required'Marker text for required fields.
rangePrefixstringNo'Percentage, allowed range'Visually hidden text read before the range, so the description names the unit and the bounds.
invalidMessagestringNo'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.
rangeMessagestringNo'Enter a percentage from {min} to {max}.'Message for a value outside the bounds. {min} and {max} become the formatted bounds.
errorPrefixstringNo'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.
reserveErrorbooleanNotrueHold 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'NoNoneBindable and read-only: whether the field holds a value and, when it does not, why.
idstringNoNoneInput id. Defaults to one generated per instance; the hint, range and error ids are always generated.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

Change 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-accent colours the focus edge and its halo. Keep it at 3:1 against the page.
  • Danger: --percentage-field-danger colours the error edge, focus ring, icon and message. The default is red-700 (6.5:1 on white).
  • Neutrals: --percentage-field-ink is the label and digits, --percentage-field-muted the hint, sign and range, --percentage-field-placeholder any placeholder, --percentage-field-hairline the resting edge (keep it at 3:1) and --percentage-field-surface the fill.
  • Worked retone for a #09090b band: --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, rangeMessage and errorPrefix.

Public CSS variables

VariableToken
--percentage-field-accentaccent
--percentage-field-dangerdanger
--percentage-field-inkink
--percentage-field-mutedmuted
--percentage-field-placeholderplaceholder
--percentage-field-hairlinehairline
--percentage-field-surfacesurface

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-describedby lists the hint, then the range, then any message, then any aria-describedby you pass. The range is read with rangePrefix first ('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 invalidMessage and rangeMessage.

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.