Numbers
Step, scale, format and read numbers in someone's language, with one keyboard map for numeric controls.
@ui-sdk/core/number provides steps and bounds, scales, formatting, parsing and the keyboard map numeric controls share.
The helpers are plain functions that run without a DOM. Slider, Number Input, Progress and Meter use the same helpers, so a control of your own steps, formats and reads numbers the way they do.
Bounds and Steps
Snap to a Step
scrubTo puts a value on the step grid within the bounds, and stepValue moves a number of steps from it. Both round away floating point noise, so 0.1 + 0.2 steps land on 0.3:
import { clamp, roundTo, scrubTo, stepValue } from "@ui-sdk/core/number"
const range = { min: 0, max: 10, step: 0.25 }
scrubTo(3.3, range).value // 3.25
stepValue(3.3, 1, range) // 3.5, off the grid moves onto it first
stepValue(9.9, 1, range) // 10
clamp(14, 0, 10) // 10
roundTo(0.1 + 0.2, 2) // 0.3The grid starts at min when it's finite, like a native range input, so { min: 1, max: 10, step: 2 } steps through 1, 3, 5, 7 and 9. maxOnGrid returns that highest value, and decimalsOf counts a number's decimals. Give the range snap points and scrubTo lands on those too, as scrubbing shows. A step of 0 has no grid, so values stay exactly as they are, within the bounds.
Loops
wrap keeps a value in a loop, for hues, angles, clock faces and carousels that go round:
import { wrap } from "@ui-sdk/core/number"
wrap(370, 0, 360) // 10
wrap(-15, 0, 360) // 345
wrap(slide + 1, 0, slides.length) // the next slide, back to the first after the lastKeys
One Keyboard Map
stepFromKey reads a key into a move, as the APG's slider and spinbutton patterns describe: Up and Right step up, Down and Left step down, Shift takes the large step and Alt the small one, Page Up and Down take the large step, and Home and End go to the bounds. stepBy applies the move:
import { stepBy, stepFromKey } from "@ui-sdk/core/number"
const range = { min: 0, max: 20, step: 0.5, largeStep: 5, smallStep: 0.1 }
function onKeyDown(event: KeyboardEvent) {
const move = stepFromKey(event, { arrows: "vertical" })
if (!move) return
event.preventDefault()
value = stepBy(value, move, range)
}import * as React from "react"
import {
formatNumber,
parseNumber,
scrubTo,
stepBy,
stepFromKey,
} from "@ui-sdk/core/number"
import {
Field,
FieldControl,
FieldDescription,
FieldLabel,
} from "@ui-sdk/react/field"
const range = { min: 0, max: 20, step: 0.5, largeStep: 5, smallStep: 0.1 }
function Example() {
const [value, setValue] = React.useState(2.5)
const [typed, setTyped] = React.useState<string | null>(null)
const shown = formatNumber(value, "en-US", { maximumFractionDigits: 1 })
const current = () => {
const parsed = typed === null ? null : parseNumber(typed, "en-US")
return parsed === null ? value : scrubTo(parsed, range).value
}
const commit = () => {
setValue(current())
setTyped(null)
}
return (
<Field>
<FieldLabel>Quantity in kg</FieldLabel>
<FieldControl
role="spinbutton"
inputMode="decimal"
autoComplete="off"
aria-valuemin={range.min}
aria-valuemax={range.max}
aria-valuenow={value}
aria-valuetext={`${shown} kg`}
value={typed ?? shown}
onChange={(event) => setTyped(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
return commit()
}
const move = stepFromKey(event, { arrows: "vertical" })
if (!move) {
return
}
event.preventDefault()
setValue(stepBy(current(), move, range))
setTyped(null)
}}
/>
<FieldDescription>
Arrows step by 0.5, Shift by 5 and Alt by 0.1. Home and End go to 0 and
20.
</FieldDescription>
</Field>
)
}
export { Example }Right to left flips Left and Right on a horizontal control, as { dir: "rtl" }; a vertical one keeps them. { arrows: "vertical" } leaves Left and Right to a text caret, as a number field does. Without largeStep, a large step is ten steps; without smallStep, a small step is one. While an input method composes, like a Japanese one typing full-width digits, stepFromKey returns null, so an arrow that picks a candidate doesn't also step.
A move is { direction, size } or { bound }, so a control can treat a bound its own way, like a slider's thumb that stops at its neighbor.
Positions and Scales
Value to Position
fractionOf gives where a value sits between bounds, from 0 to 1, and valueAt gives the value at a position. Together they place a thumb, fill a bar and turn a press into a value:
import { fractionOf, scrubTo, valueAt } from "@ui-sdk/core/number"
fractionOf(30, { min: 0, max: 120 }) // 0.25
const pressed = valueAt((event.clientX - box.left) / box.width, range)
scrubTo(pressed, range).valueValues outside the bounds sit at an end, and bounds that are the same give 0.
Log and Custom Scales
On a log scale, each tenfold takes the same distance, as frequency, volume and zoom controls need. Pass "log", with a positive min, or a to and from pair that maps values into an even space and back:
fractionOf(200, { min: 20, max: 20000 }, "log") // 0.33: 20 to 200 is a third of the way
valueAt(0.5, { min: 20, max: 20000 }, "log") // 632, halfway by ratio
const area = { to: Math.sqrt, from: (position: number) => position * position }
fractionOf(25, { min: 0, max: 100 }, area) // 0.5: a size that feels even by area, like a brushimport * as React from "react"
import { Field, FieldControl, FieldLabel } from "@ui-sdk/react/field"
import {
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
} from "@ui-sdk/react/segmented-control"
import { formatNumber, fractionOf, valueAt } from "@ui-sdk/core/number"
const range = { min: 1, max: 180 }
function Example() {
const [scale, setScale] = React.useState<"linear" | "log">("log")
const [angle, setAngle] = React.useState(45)
const position = fractionOf(angle, range, scale)
const degrees = `${formatNumber(angle, "en-US", { maximumFractionDigits: 0 })}°`
return (
<div data-slot="stack">
<div data-slot="controls">
<SegmentedControl
aria-label="Scale"
value={scale}
onValueChange={(next) => next && setScale(next as "linear" | "log")}
>
<SegmentedControlIndicator />
<SegmentedControlItem value="linear">Linear</SegmentedControlItem>
<SegmentedControlItem value="log">Log</SegmentedControlItem>
</SegmentedControl>
</div>
<div aria-hidden="true" data-slot="number-scale-dial">
<div
data-needle
data-slot="number-scale-needle"
style={{ rotate: `${-angle}deg` }}
/>
</div>
<Field>
<div data-slot="row">
<FieldLabel>Rotation</FieldLabel>
<span data-slot="hint">
<span data-angle>{degrees}</span>,{" "}
<span data-position>{Math.round(position * 100)}% along</span>
</span>
</div>
<FieldControl
type="range"
aria-valuetext={degrees}
min={0}
max={1}
step={0.001}
value={position}
onChange={(event) =>
setAngle(valueAt(Number(event.target.value), range, scale))
}
/>
</Field>
</div>
)
}
export { Example }To step a scaled control evenly, step its position and read the value back, like valueAt(stepBy(fraction, move, { min: 0, max: 1, step: 0.01 }), range, "log").
Scrubbing
Snap While Scrubbing
Give a range snap points and a threshold, and a scrub lands on a point when it comes that close, like a timeline's chapters or an editor's keyframes. scrubTo reads a pointer or a slider, and stepBy takes the same points for keys: a step stops at the first point it reaches ahead, and leaves the point it's on freely:
import { scrubTo, stepBy, stepFromKey } from "@ui-sdk/core/number"
const range = {
min: 0,
max: 60,
step: 0.1,
snap: { points: [0, 9.5, 24, 38.5, 52], threshold: 1.5 },
}
scrubTo(23, range) // { value: 24, point: 24 }
scrubTo(23, range, { snap: false }) // { value: 23, point: undefined }
function onKeyDown(event: KeyboardEvent) {
const move = stepFromKey(event)
if (!move) return
event.preventDefault()
time = stepBy(time, move, range, { snap: !event.metaKey })
}{ snap: false } ignores the points, like while Cmd is held or for a typed number, and still lands on the step grid. A slider's input event doesn't say which keys are down, so read them from its pointer events:
<input
type="range"
onPointerDown={(event) => (free = event.metaKey)}
onPointerMove={(event) => (free = event.metaKey)}
onChange={(event) =>
setTime(scrubTo(Number(event.target.value), range, { snap: !free }).value)
}
/>import * as React from "react"
import {
formatNumber,
fractionOf,
scrubTo,
stepBy,
stepFromKey,
} from "@ui-sdk/core/number"
const sections = [
{ at: 0, label: "Intro" },
{ at: 9.5, label: "Verse" },
{ at: 24, label: "Chorus" },
{ at: 38.5, label: "Bridge" },
{ at: 52, label: "Outro" },
]
const range = {
min: 0,
max: 60,
step: 0.1,
largeStep: 5,
snap: { points: sections.map((section) => section.at), threshold: 1.5 },
}
const clock = (time: number) =>
`${Math.floor(time / 60)}:${formatNumber(time % 60, "en-US", {
minimumIntegerDigits: 2,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}`
function Example() {
const [time, setTime] = React.useState(17)
const free = React.useRef(false)
const section = sections.find((candidate) => candidate.at === time)
const track = (event: React.PointerEvent) => {
free.current = event.metaKey || event.ctrlKey
}
return (
<div data-slot="scrubber">
<div data-slot="scrubber-track">
{sections.map((candidate) => (
<div
key={candidate.label}
data-slot="scrubber-section"
data-active={candidate === section || undefined}
style={{ left: `${fractionOf(candidate.at, range) * 100}%` }}
>
{candidate.label}
</div>
))}
<div
data-slot="scrubber-playhead"
data-playhead
style={{ left: `${fractionOf(time, range) * 100}%` }}
/>
</div>
<input
type="range"
aria-label="Playhead"
aria-valuetext={
section ? `${clock(time)}, ${section.label}` : clock(time)
}
min={range.min}
max={range.max}
step={range.step}
value={time}
onPointerDown={track}
onPointerMove={track}
onChange={(event) =>
setTime(
scrubTo(Number(event.target.value), range, { snap: !free.current })
.value
)
}
onKeyDown={(event) => {
const move = stepFromKey(event)
if (!move) {
return
}
event.preventDefault()
setTime(
stepBy(time, move, range, {
snap: !(event.metaKey || event.ctrlKey),
})
)
}}
/>
<p data-slot="scrubber-readout">
<span data-time>{clock(time)}</span>
<span data-section>{section?.label ?? "Between sections"}</span>
</p>
<p data-slot="hint">Hold ⌘ or Ctrl to scrub freely.</p>
</div>
)
}
export { Example }point says which snap point a value is pulled to, so a release can commit point ?? value. With { ease: true }, a value near a point moves toward it instead of jumping: not at all at the threshold, faster as it closes in, and onto the point at the end. Show value while dragging, and land on point:
scrubTo(23.25, range, { ease: true }) // { value: 23.625, point: 24 }
scrubTo(22.5, range, { ease: true }) // { value: 22.5, point: 24 }, at the thresholdPass a curve over closeness, from 0 at the threshold to 1 on the point, like { ease: (t) => t * t }, to change how the pull builds. Collapse Past a Threshold eases a sidebar into collapsing.
A control that leaves Up and Down to the page, like a timeline in a list of tracks, passes { arrows: "horizontal" } to stepFromKey.
Formatting
Numbers in Any Language
formatNumber shows a value through Intl.NumberFormat in a locale: currencies, percents, units and compact counts, with the locale's own digits and separators. Pass a function for text of your own. Null and values that aren't finite show as empty text:
import { formatNumber } from "@ui-sdk/core/number"
formatNumber(1234.5, "de-DE", { style: "currency", currency: "EUR" }) // "1.234,50 €"
formatNumber(1234.5, "ja-JP", { style: "currency", currency: "JPY" }) // "¥1,235"
formatNumber(1234.5, "zh-CN", { style: "currency", currency: "CNY" }) // "¥1,234.50"
formatNumber(0.256, "en-US", { style: "percent", maximumFractionDigits: 1 }) // "25.6%"
formatNumber(1234567, "en-US", { notation: "compact" }) // "1.2M"
formatNumber(3, "en-US", (value) => `${value} of 5 stars`) // "3 of 5 stars"Use the same text for a control's aria-valuetext, so screen readers hear what's shown. numberFormatter returns the Intl.NumberFormat itself, built once per locale and options.
Ranges and Parts
formatNumberRange writes what both ends share once, and formatNumberToParts returns each digit group and symbol, to style them apart or roll digits like an odometer:
import { formatNumberRange, formatNumberToParts } from "@ui-sdk/core/number"
formatNumberRange(5, 10, "en-US", { style: "currency", currency: "USD" }) // "$5.00 – $10.00"
formatNumberToParts(1234.5, "en-US", { style: "currency", currency: "USD" })
// [{ type: "currency", value: "$" }, { type: "integer", value: "1" }, { type: "group", value: "," }, …]import * as React from "react"
import {
formatNumber,
formatNumberRange,
formatNumberToParts,
} from "@ui-sdk/core/number"
const locales = ["en-US", "de-DE", "ja-JP", "zh-CN", "ar-EG", "hi-IN"]
const styles: Record<string, Intl.NumberFormatOptions> = {
Currency: { style: "currency", currency: "EUR" },
Percent: { style: "percent", maximumFractionDigits: 1 },
Compact: { notation: "compact" },
Unit: { style: "unit", unit: "kilometer-per-hour" },
}
const values: Record<string, [number, number]> = {
Currency: [1234.5, 2500],
Percent: [0.256, 0.5],
Compact: [1234567, 9800000],
Unit: [88, 120],
}
function Example() {
const [locale, setLocale] = React.useState("en-US")
const [style, setStyle] = React.useState("Currency")
const [value, end] = values[style]
const format = styles[style]
return (
<div data-slot="stack">
<div data-slot="controls">
<select
aria-label="Locale"
value={locale}
onChange={(event) => setLocale(event.target.value)}
>
{locales.map((option) => (
<option key={option}>{option}</option>
))}
</select>
<select
aria-label="Style"
value={style}
onChange={(event) => setStyle(event.target.value)}
>
{Object.keys(styles).map((option) => (
<option key={option}>{option}</option>
))}
</select>
</div>
<div data-slot="number-format-result">
<p data-formatted data-slot="number-format-value">
{formatNumberToParts(value, locale, format).map((part, index) => (
<span key={index} data-type={part.type}>
{part.value}
</span>
))}
</p>
<dl>
<dt>Plain</dt>
<dd data-plain>{formatNumber(value, locale, format)}</dd>
<dt>Range</dt>
<dd data-range>{formatNumberRange(value, end, locale, format)}</dd>
</dl>
</div>
</div>
)
}
export { Example }Typing a Number
Read What People Type
parseNumber reads a number typed in a locale's format: its digits, group and decimal separators, minus signs, and currency, percent and unit text. It returns null for text that isn't a number, and percent formats divide by 100. isPartialNumber says whether text could still become one, so a field can wait on a lone "-" instead of rejecting it:
import { isPartialNumber, parseNumber } from "@ui-sdk/core/number"
parseNumber("1.234,5", "de-DE") // 1234.5
parseNumber("٣٫٥", "ar-EG") // 3.5
parseNumber("50 %", "fr-FR", { style: "percent" }) // 0.5
parseNumber("-", "de-DE") // null
isPartialNumber("-", "de-DE") // trueimport * as React from "react"
import { formatNumber, isPartialNumber, parseNumber } from "@ui-sdk/core/number"
import {
Field,
FieldControl,
FieldDescription,
FieldLabel,
} from "@ui-sdk/react/field"
import { useLog } from "./log"
const locales = ["en-US", "de-DE", "fr-FR", "ar-EG"]
function Example() {
const log = useLog()
const [locale, setLocale] = React.useState("de-DE")
const [text, setText] = React.useState("1.234,5")
const value = parseNumber(text, locale)
const typing = value === null && isPartialNumber(text, locale)
const read = (next: string, nextLocale: string) => {
const parsed = parseNumber(next, nextLocale)
const verdict =
parsed !== null
? String(parsed)
: isPartialNumber(next, nextLocale)
? "keep typing"
: "not a number"
log(`“${next}” in ${nextLocale}: ${verdict}`)
}
return (
<div data-slot="stack">
<div data-slot="controls">
<select
aria-label="Locale"
value={locale}
onChange={(event) => {
setLocale(event.target.value)
read(text, event.target.value)
}}
>
{locales.map((option) => (
<option key={option}>{option}</option>
))}
</select>
</div>
<Field>
<FieldLabel>Amount</FieldLabel>
<FieldControl
inputMode="decimal"
value={text}
aria-invalid={value === null && !typing}
onChange={(event) => {
setText(event.target.value)
read(event.target.value, locale)
}}
/>
<FieldDescription>Like {formatNumber(9876.5, locale)}</FieldDescription>
</Field>
</div>
)
}
export { Example }API Reference
All of these functions come from @ui-sdk/core/number.
| You Want To | Use |
|---|---|
| Keep a value on a step grid within bounds | scrubTo, stepValue, clamp, roundTo, decimalsOf, maxOnGrid |
| Keep a value in a loop | wrap |
| Turn keys into steps | stepFromKey, stepBy |
| Map values to positions and back | fractionOf, valueAt |
| Snap a scrub to points | scrubTo, stepBy |
| Show numbers in someone's language | formatNumber, formatNumberRange, formatNumberToParts, numberFormatter |
| Read typed numbers | parseNumber, isPartialNumber |