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
<!--
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"
> {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, #1d4ed8);
--_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>
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.