# 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.

- ID: `cmp_percentage_field_01`
- Slug: `percentage-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T04:11:00Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/percentage-field-01?variant=neutral
- Preview: https://pagesugar.com/preview/percentage-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-d9e593c72342b25ebe9c2d0069bdc588d43355b654c62fd4a342dda48804eb45` |
| `blue` | Blue accent | no | `sha256-393e0e15ae635c771fb99a00842e4662be3352c87e6bf21b7d0db3fbd1c0ed97` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/percentage-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

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.

Required props: `label`, `storage`

```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>
```

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.

## Usage guide

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

```ts
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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | 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 |  | 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 |  | Form field name. A hidden input submits the stored value under it. |
| `hint` | `string` | no |  | 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 |  | Bindable and read-only: whether the field holds a value and, when it does not, why. |
| `id` | `string` | no |  | Input id. Defaults to one generated per instance; the hint, range and error ids are always generated. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--percentage-field-accent` |
| `danger` | `--percentage-field-danger` |
| `ink` | `--percentage-field-ink` |
| `muted` | `--percentage-field-muted` |
| `placeholder` | `--percentage-field-placeholder` |
| `hairline` | `--percentage-field-hairline` |
| `surface` | `--percentage-field-surface` |

## Accessibility

- 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `PercentageField.svelte`
- Suggested directory: `src/lib/components/percentage-field-01`
- Files: 2
- Artifact digest: `sha256-d9e593c72342b25ebe9c2d0069bdc588d43355b654c62fd4a342dda48804eb45`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `PercentageField.svelte`

Role: entry · 17807 bytes · SHA-256 `34aabd5a8bb6f4bad851b69ffc3df60bb6639628be41aea4f9e6998a60f4f944`

```svelte
<!--
	A percentage field with a declared storage convention. People always type the percent they
	see (12.5); the bound value holds it in the unit you chose: 0.125 as a ratio, 12.5 as a
	percent, or 1250 in basis points. The field is sized to the widest value its bounds allow,
	the digits sit hard against the percent sign in tabular numerals, and the allowed range is
	set beside it. Conversion moves the decimal point in text, so no float artefacts reach you.
-->
<script lang="ts" module>
	import type { PercentageStorage as Storage } from './percentage';

	/** 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 = Storage;
	export type PercentageFieldSize = 'sm' | 'md';
	/** Why the field holds no value, or 'valid' when it does. */
	export type PercentageFieldStatus = 'empty' | 'valid' | 'out-of-range' | 'invalid';
</script>

<script lang="ts">
	import { untrack } from 'svelte';
	import type { HTMLInputAttributes } from 'svelte/elements';
	import {
		clampDigits,
		formatPercent,
		formatRange,
		formatUnits,
		parsePercent,
		percentSign,
		shift,
		storedToUnits,
		unitsToStored,
		widestText
	} from './percentage';

	type Passthrough = Omit<
		HTMLInputAttributes,
		| 'type'
		| 'value'
		| 'id'
		| 'name'
		| 'size'
		| 'min'
		| 'max'
		| 'step'
		| 'required'
		| 'class'
		| 'children'
		| 'inputmode'
		| 'aria-invalid'
	>;

	interface Props extends Passthrough {
		/** Visible label. Always shown. */
		label: string;
		/**
		 * 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; status says which.
		 */
		value?: number | null;
		/** How value is stored: 'ratio' (0.125), 'percent' (12.5) or 'basis-points' (1250). */
		storage: PercentageStorage;
		/** Lowest allowed percent, in displayed units. */
		min?: number;
		/** Highest allowed percent, in displayed units. Raise it for growth rates above 100. */
		max?: number;
		/** Decimals shown and kept, 0 to 2. Typed digits beyond it round half away from zero. */
		fractionDigits?: number;
		/** Arrow Up and Down change the value by this many percent; Shift multiplies it by ten. */
		step?: number;
		/** BCP 47 tag for the decimal mark, the sign's side and spacing, and the range text. */
		locale?: string;
		/** Form field name. A hidden input submits the stored value under it. */
		name?: string;
		/** Help text under the label. */
		hint?: string;
		/** Validation message from your code. Takes the place of the field's own messages. */
		error?: string | null;
		/** Native required attribute on the visible input, with a visible Required marker. */
		required?: boolean;
		/** Marker text for required fields. */
		requiredLabel?: string;
		/** Read before the range by screen readers, naming the unit: "Percentage, allowed range". */
		rangePrefix?: string;
		/** Message when the text is not a number. {example} becomes a sample such as 12.5. */
		invalidMessage?: string;
		/** Message when the value is outside the bounds. {min} and {max} become 0% and 100%. */
		rangeMessage?: string;
		/** Read before any error by screen readers only. */
		errorPrefix?: string;
		/** sm 32 px or md 36 px tall, matching buttons; 44 px on touch screens. */
		size?: PercentageFieldSize;
		/** Hold one line under the field for messages, so showing one moves nothing below. On by default. */
		reserveError?: boolean;
		/** Bindable, read-only: whether the field holds a value, and if not, why. */
		status?: PercentageFieldStatus;
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		value = $bindable(null),
		storage,
		min = 0,
		max = 100,
		fractionDigits = 0,
		step = 1,
		locale = 'en',
		name,
		hint,
		error = null,
		required = false,
		requiredLabel = 'Required',
		rangePrefix = 'Percentage, allowed range',
		invalidMessage = 'Enter the percentage as a number, such as {example}.',
		rangeMessage = 'Enter a percentage from {min} to {max}.',
		errorPrefix = 'Error:',
		size = 'md',
		reserveError = true,
		// eslint-disable-next-line no-useless-assignment -- a bindable output, read by the parent
		status = $bindable(),
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const hintId = `${uid}-hint`;
	const rangeId = `${uid}-range`;
	const errorId = `${uid}-error`;

	const digits = $derived(clampDigits(fractionDigits));
	const sign = $derived(percentSign(locale));
	const range = $derived(formatRange(min, max, digits, locale));
	/* Room for the widest allowed value, plus one character for the caret. */
	const chars = $derived(widestText(min, max, digits, locale) + 1);

	/** The text for a stored value, or empty when there is none. */
	function textFor(stored: number | null | undefined): string {
		const units = storedToUnits(stored, storage, digits);
		return units === null ? '' : formatUnits(units, digits, locale);
	}

	let text = $state(untrack(() => textFor(value)));

	const parsed = $derived(parsePercent(text, digits, locale));
	/* A sign typed into the text stands in for the fixed one until blur tidies it away. */
	const typedSign = $derived(/[%\u066a\uff05]/.test(text));
	const current: PercentageFieldStatus = $derived.by(() => {
		if (parsed.kind !== 'number') return parsed.kind;
		const percent = shift(parsed.units, -digits);
		return percent < min || percent > max ? 'out-of-range' : 'valid';
	});
	const faulty = (s: PercentageFieldStatus) => s === 'invalid' || s === 'out-of-range';

	/*
	 * The field's own message appears once someone leaves a field they got wrong, and clears as
	 * soon as the text is right again. A value that arrives out of range is flagged at once.
	 */
	let flagged = $state(untrack(() => faulty(current)));
	/* What is wrong with the text, whether or not it is showing yet. */
	const verdict = $derived.by(() => {
		if (!faulty(current)) return null;
		if (current === 'invalid') {
			const example = formatUnits(digits ? 125 * 10 ** (digits - 1) : 15, digits, locale);
			return invalidMessage.replaceAll('{example}', example);
		}
		return rangeMessage
			.replaceAll('{min}', formatPercent(min, digits, locale))
			.replaceAll('{max}', formatPercent(max, digits, locale));
	});
	const ownMessage = $derived(flagged ? verdict : null);
	const message = $derived(error || ownMessage);
	const invalid = $derived(!!message);

	const describedBy = $derived(
		[
			...new Set(
				[
					hint && hintId,
					rangeId,
					invalid && errorId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ')
	);

	/* The value last written or received, so an outside change can be told from our own. */
	let known = untrack(() => value);

	$effect.pre(() => {
		const next = value;
		if (Object.is(next, known)) return;
		known = next;
		untrack(() => {
			text = textFor(next);
			flagged = faulty(current);
		});
	});

	/* Set now, so a parent reading the bound status renders the same on the server. */
	// eslint-disable-next-line no-useless-assignment -- a bindable output, read by the parent
	status = untrack(() => current);
	$effect.pre(() => {
		status = current;
	});

	/* A new storage, precision or locale redraws the text from the value it has to show. */
	let format = untrack(() => `${storage}|${digits}|${locale}`);
	$effect.pre(() => {
		const next = `${storage}|${digits}|${locale}`;
		if (next === format) return;
		format = next;
		untrack(() => {
			text = textFor(value);
			flagged = faulty(current);
		});
	});

	/* New bounds re-judge what is typed, so value follows: a number only while it is in range. */
	let bounds = untrack(() => `${min}|${max}`);
	$effect.pre(() => {
		const next = `${min}|${max}`;
		if (next === bounds) return;
		bounds = next;
		untrack(() => {
			if (parsed.kind === 'number') commit();
		});
	});

	/* The value the form submits: only one the text currently shows as valid. */
	const submitted = $derived(
		current === 'valid' && value != null && Number.isFinite(value) ? String(value) : ''
	);

	/** Writes the stored value for the current text: a number only when it is valid. */
	function commit() {
		const next =
			current === 'valid' && parsed.kind === 'number'
				? unitsToStored(parsed.units, storage, digits)
				: null;
		known = next;
		value = next;
		if (!faulty(current)) flagged = false;
	}

	function oninput(event: Event) {
		commit();
		rest.oninput?.(event as Event & { currentTarget: EventTarget & HTMLInputElement });
	}

	/* Leaving the field tidies valid text into the locale's form (12.50 becomes 12.5). */
	function onblur(event: FocusEvent) {
		if (current === 'valid' && parsed.kind === 'number')
			text = formatUnits(parsed.units, digits, locale);
		else if (faulty(current)) flagged = true;
		rest.onblur?.(event as FocusEvent & { currentTarget: EventTarget & HTMLInputElement });
	}

	/* Arrow Up and Down step within the bounds; Shift steps ten times as far. */
	function onkeydown(event: KeyboardEvent) {
		rest.onkeydown?.(event as KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement });
		if (event.defaultPrevented || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
		const target = event.currentTarget as HTMLInputElement;
		if (target.readOnly || target.disabled || parsed.kind === 'invalid') return;
		event.preventDefault();
		const delta = Math.max(1, Math.round(shift(step, digits))) * (event.shiftKey ? 10 : 1);
		const low = Math.ceil(shift(min, digits));
		const high = Math.floor(shift(max, digits));
		const from =
			parsed.kind === 'number'
				? parsed.units
				: event.key === 'ArrowUp'
					? low - delta
					: high + delta;
		const next = Math.min(high, Math.max(low, from + (event.key === 'ArrowUp' ? delta : -delta)));
		text = formatUnits(next, digits, locale);
		commit();
	}

	let input: HTMLInputElement | undefined = $state();

	/* Native constraint validation follows the field's own verdict, so a form will not submit it. */
	$effect(() => {
		input?.setCustomValidity(verdict ?? '');
	});

	/* A form reset puts back the input's default text without an input event, so follow it. */
	$effect(() => {
		const form = input?.form;
		if (!form) return;
		const onreset = () =>
			queueMicrotask(() => {
				if (!input) return;
				text = input.value;
				commit();
				flagged = false;
			});
		form.addEventListener('reset', onreset);
		return () => form.removeEventListener('reset', onreset);
	});

	/* A press on the sign or the border focuses the input, as a press inside one box should. */
	function focusInput(event: PointerEvent) {
		if (!input || event.button !== 0 || event.target === input || input.disabled) return;
		event.preventDefault();
		input.focus();
	}
</script>

<div
	class={['percentage-field min-w-0', className]}
	data-size={size}
	data-invalid={invalid ? '' : undefined}
>
	<div class="grid gap-2">
		<div class="min-w-0">
			<label
				for={inputId}
				class={[
					'block leading-5 font-medium text-pretty break-words text-(--_ink)',
					size === 'sm' ? 'text-[13px]' : 'text-sm'
				]}
			>
				{label}{#if required}
					<!-- The native required attribute already says so to assistive technology. -->
					<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
						>&ensp;{requiredLabel}</span
					>{/if}
			</label>
			{#if hint}
				<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
					{hint}
				</p>
			{/if}
		</div>

		<div class="min-w-0">
			<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
				<!-- Digits and the sign always run left to right, as numbers do in RTL text. -->
				<!-- svelte-ignore a11y_no_static_element_interactions -->
				<div
					class={[
						'percentage-field__control flex h-(--_h) max-w-full min-w-24 items-stretch border',
						'border-(--_edge) bg-(--_surface) text-(--_ink)',
						'pointer-coarse:text-base',
						size === 'sm' ? 'rounded-md text-[13px]' : 'rounded-lg text-base'
					]}
					dir="ltr"
					data-sign={sign.position}
					onpointerdown={focusInput}
				>
					{#if sign.position === 'prefix'}
						<span
							class={[
								'percentage-field__sign flex shrink-0 items-center ps-3 text-(--_muted)',
								sign.spaced && 'pe-1',
								typedSign && 'invisible'
							]}
							aria-hidden="true">{sign.symbol}</span
						>
					{/if}
					<input
						bind:this={input}
						{...rest}
						bind:value={text}
						type="text"
						inputmode="decimal"
						autocomplete="off"
						spellcheck={false}
						{required}
						id={inputId}
						dir="ltr"
						aria-describedby={describedBy}
						aria-invalid={invalid ? 'true' : undefined}
						style:--_chars={chars}
						{oninput}
						{onblur}
						{onkeydown}
						class={[
							'percentage-field__input w-[calc(var(--_chars)*1ch)] min-w-0 flex-1 bg-transparent font-medium text-(--_ink) tabular-nums outline-none',
							'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
							'pointer-coarse:text-base',
							size === 'sm' ? 'text-[13px] leading-5' : 'text-base leading-6',
							sign.position === 'suffix' ? 'ps-3 text-end' : 'pe-3 text-start'
						]}
					/>
					{#if sign.position === 'suffix'}
						<span
							class={[
								'percentage-field__sign flex shrink-0 items-center pe-3 text-(--_muted)',
								sign.spaced && 'ps-1',
								typedSign && 'invisible'
							]}
							aria-hidden="true">{sign.symbol}</span
						>
					{/if}
				</div>
				<p id={rangeId} class="text-[13px] leading-5 text-(--_muted) tabular-nums">
					<span class="sr-only">{`${rangePrefix} `}</span><bdi dir="ltr">{range}</bdi>
				</p>
			</div>

			{#if name}
				<input type="hidden" {name} value={submitted} disabled={rest.disabled} form={rest.form} />
			{/if}

			{#if invalid}
				<p id={errorId} class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_danger)">
					<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
						<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
							<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
							<path
								d="M8 4.75v3.5"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
							/>
							<circle cx="8" cy="11" r="1" fill="currentColor" />
						</svg>
					</span>
					<span class="min-w-0 text-pretty break-words"
						><span class="sr-only">{`${errorPrefix} `}</span>{message}</span
					>
				</p>
			{:else if reserveError}
				<div class="mt-1 h-5" aria-hidden="true"></div>
			{/if}
		</div>
	</div>
</div>

<style>
	/* Public tokens: set --percentage-field-* on the field or any ancestor to retone it. */
	.percentage-field {
		--_accent: var(--percentage-field-accent, #18181b);
		--_danger: var(--percentage-field-danger, #b91c1c);
		--_ink: var(--percentage-field-ink, #18181b);
		--_muted: var(--percentage-field-muted, #52525b);
		--_placeholder: var(--percentage-field-placeholder, #71717a);
		/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
		--_hairline: var(--percentage-field-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--percentage-field-surface, #ffffff);

		--_h: 2.25rem;
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
	}
	.percentage-field[data-size='sm'] {
		--_h: 2rem;
	}
	@media (pointer: coarse) {
		.percentage-field[data-size] {
			--_h: 2.75rem;
		}
	}

	/* Invalid: the edge carries the error colour; focus keeps the accent ring (below). */
	.percentage-field[data-invalid] {
		--_edge: var(--_danger);
	}

	/*
	 * The edge, fill and halo change through the rules below, which follow the input inside, so
	 * the transition that animates them lives here with them.
	 */
	.percentage-field__control {
		transition-property: border-color, box-shadow, background-color;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	@media (prefers-reduced-motion: reduce) {
		.percentage-field__control {
			transition-duration: 0ms;
		}
	}

	/* Hover only where typing is possible: one step darker edge. */
	@media (hover: hover) {
		.percentage-field__control:hover:not(:has(input:disabled, input[readonly])) {
			--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
		}
		.percentage-field[data-invalid] .percentage-field__control:hover {
			--_edge: var(--_danger);
		}
	}

	/* Focus: a two-pixel edge in the accent (one border plus one ring), with a soft halo outside. */
	.percentage-field__control:has(input:focus-visible) {
		border-color: var(--_ring);
		box-shadow:
			0 0 0 1px var(--_ring),
			0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
	}
	/* Focused while invalid: the danger edge stays, and the accent ring sits two pixels outside it. */
	.percentage-field[data-invalid] .percentage-field__control:has(input:focus-visible) {
		border-color: var(--_danger);
		box-shadow:
			0 0 0 2px var(--_surface),
			0 0 0 4px var(--_ring);
	}

	/* Read-only: a raised fill, full-strength text, still focusable and selectable. */
	.percentage-field__control:has(input[readonly]) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}

	/* Disabled: raised fill, faded, and not interactive. */
	.percentage-field__control:has(input:disabled) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
		opacity: 0.55;
		cursor: not-allowed;
	}

	/* Forced colours drop shadows and repaint borders, so focus becomes a system-colour outline. */
	@media (forced-colors: active) {
		.percentage-field__control:has(input:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>
```

#### `percentage.ts`

Role: helper · 8255 bytes · SHA-256 `9601b3ced2cbf90af71fa3957698f5a2065d77415924a4e87238a91852d220d8`

```ts
/**
 * 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 });
	}
}
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-d9e593c72342b25ebe9c2d0069bdc588d43355b654c62fd4a342dda48804eb45`
- Entry: `PercentageField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_percentage_field_01/1.0.0/neutral/sha256-d9e593c72342b25ebe9c2d0069bdc588d43355b654c62fd4a342dda48804eb45/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_percentage_field_01/1.0.0/neutral/sha256-d9e593c72342b25ebe9c2d0069bdc588d43355b654c62fd4a342dda48804eb45/bundle.zip (13453 bytes, sha256 `8546623779650a5760c2d3dd812cbed85942a089310b4f76403bd33387145a69`)

Files:

- `PercentageField.svelte` (entry, 17807 bytes): https://pagesugar.com/artifacts/cmp_percentage_field_01/1.0.0/neutral/sha256-d9e593c72342b25ebe9c2d0069bdc588d43355b654c62fd4a342dda48804eb45/source/PercentageField.svelte
- `percentage.ts` (helper, 8255 bytes): https://pagesugar.com/artifacts/cmp_percentage_field_01/1.0.0/neutral/sha256-d9e593c72342b25ebe9c2d0069bdc588d43355b654c62fd4a342dda48804eb45/source/percentage.ts
