Skip to content
fayeuiStatic / 0.1.0
LLMs
Components / Static edition

Numeric and specialized input

Number fields, sliders, one-time codes and fractional ratings.

Quick start

After the application setup, copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.

'use client';
import { useId } from 'react';
import { Plus, Minus } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { NumberField } from '@fayegram-ai/faye-ui/controls/number-field';
import { Rating } from '@fayegram-ai/faye-ui/controls/rating';
import { Stack } from '@fayegram-ai/faye-ui/layout/layout';

export function TripNumbers() {
  const id = useId();
  return <Stack gap="4">
    <label htmlFor={id}>Number of people</label>
    <NumberField.Root id={id} name="people" locale="en-US" min={1} max={10} defaultValue={2}>
      <NumberField.Group>
        <NumberField.Decrement aria-label="One fewer person"><Icon icon={Minus} /></NumberField.Decrement>
        <NumberField.Input />
        <NumberField.Increment aria-label="One more person"><Icon icon={Plus} /></NumberField.Increment>
      </NumberField.Group>
    </NumberField.Root>
    <Rating name="rating" label="How was your day?" defaultValue={3.5} precision={0.5} />
  </Stack>;
}

API reference: Icon, Number Field, Rating.

Usage contract

NumberField uses an explicit locale. Range thumbs need distinct names. OTPField requires a declared slot length for stable markup. Rating supports fractional precision and an optional clear action; its native radio group is bounded to at most 100 choices.

Customization

Use semantic tokens for visual changes, public component parts for structural customization, and explicit props for instance behavior. The surrounding site lets you compare light and dark, different brands and density.

Source and verification

The examples below import actual public package entries. Full source is downloadable. Source coverage does not establish keyboard, hydration or browser compatibility; those require the application test suite.

Rating previews

Pointer hover previews a precision step without changing the selected or submitted native radio value. onPreviewChange receives the preview value, then null on pointer exit, for an optional visible hint. Keyboard selection and onValueChange still own the committed value. Disabled and read-only ratings do not preview; touch activation remains a direct selection.

Fractional pointer targets follow the centered star artwork, including logical direction. Keyboard focus surrounds the entire active star, rather than a narrow fractional slice; keyboard selection clears any stale pointer preview. NumberField gives its input one group frame on keyboard focus, while increment/decrement controls use their own inset frame.

Rating appearance and preview

Empty rating values use outlined stars by default; selected values fill the artwork. Hover previews highlight the values that will be selected without changing form submission, and reset on pointer leave or keyboard interaction. Keyboard focus marks one complete item with a compact inset frame.

Pass icon and emptyIcon to use matching custom artwork, such as the heart example. Both are decorative React nodes; accessible labels, fractional hit regions, native radio keyboard behavior, RTL and form values remain owned by Rating. Use matching icon dimensions; the visual frame follows --faye-ui-size-icon-lg. Theme accent tokens control filled and preview colors.

Focus, narrow layouts and scrubbing

NumberField fits the available inline width. Its input can shrink while the increment and decrement controls retain their targets, including with enlarged text.

Slider keyboard focus uses one inset ring on the visible thumb; its hidden native input does not draw a second outline. Hover and drag feedback animate without changing the track geometry, and system/provider reduced motion removes those transitions. On coarse pointers the horizontal control is at least 44px high and the vertical control is at least 44px wide.

Vertical sliders keep their value readout, track, endpoint thumbs and marks inside the root height. The track uses the remaining height after the readout and spacing; set the root block size when a longer track is needed. Mark dots align with the track in LTR and RTL. The vertical examples include both a readout and following content to make their layout visible.

NumberField actions at their limits use disabled styling and do not highlight as available actions.

Read-only NumberField values remain focusable and readable. Increment/decrement parts expose unavailable actions through aria-disabled, including custom-rendered parts, and suppress hover/press styling. Scrub areas use a neutral cursor when read-only and an unavailable cursor when disabled; neither state changes the value through dragging.

OTP inputs shrink within their containing block, including the grouped-code example with OTPField.Separator. Coarse-pointer inputs retain readable touch typography. NumberField.ScrubArea offers pointer dragging on the label while the input and increment/decrement buttons remain available alternatives. Its optional custom cursor depends on browser Pointer Lock support; Safari intentionally omits that cursor.

An uncontrolled Rating follows its current native form owner when reset, including external forms mounted later or replaced under the same ID. Canceled resets preserve its selection. Controlled values remain owned by the application. Read-only ratings keep the selected artwork and form value; combining readOnly with disabled also applies the disabled appearance and omits the value from submission.

Interaction variants and track feedback

Enabled sliders use interaction={{ variant: 'minimal', level: 'standard' }} by default. The rail and selected fill become slightly thicker on hover, dragging or keyboard-visible focus. A horizontal rail grows taller; a vertical rail grows wider. Expansion stays centered while thumb size, value positions, marks and layout stay stable. Feedback continues while dragging outside the control. Disabled controls and individually disabled thumbs remain idle.

Interaction appearance has three independent choices:

SettingChoicesPurpose
variantclassic, minimal, softSelects a complete feedback preset. Classic retains existing thumb feedback; minimal expands the rail; soft adds a gentle tonal underlay.
levelstandard, enhancedChooses the strength of the selected preset. Default track expansion is 1.3× or 1.5×.
effects.track.typeexpand, highlight, noneOverrides the track behavior. Highlight adds tonal feedback without expansion; none suppresses optional track decoration.

An expand effect accepts a finite scale from 1 to 2. Expand and highlight accept an optional nonnegative duration in milliseconds. Without an override, transitions use the fast-motion token. System and scoped reduced motion always remove animation, including when a custom duration is supplied. The existing single inset keyboard-focus indicator remains available for every variant.

import { FayeUIProvider } from '@fayegram-ai/faye-ui/provider/provider';
import { Slider } from '@fayegram-ai/faye-ui/controls/slider';

export function ListeningVolume() {
  return <FayeUIProvider interaction={{ variant: 'soft', level: 'standard' }}>
    <Slider.Root defaultValue={40} interaction={{
      effects: { track: { type: 'expand', scale: 1.4, duration: 170 } }
    }}>
      <Slider.Control><Slider.Track><Slider.Indicator />
        <Slider.Thumb aria-label="Listening volume" />
      </Slider.Track></Slider.Control>
    </Slider.Root>
  </FayeUIProvider>;
}

The live comparison demonstrates all three variants, both levels, horizontal and vertical sliders, explicit effects and disabled controls. The same options work on Slider.DiscreteRoot and ScrollArea.Root. These visual settings do not change values, steps, form submission or keyboard behavior.

In forced-color mode, the selected range and remaining rail use distinct system colors. Thumbs retain a single visible keyboard-focus indicator, and disabled sliders use the system unavailable color without stacking translucent layers.

Live examples

Try the controls below, then read or copy the complete example to adapt it for your application.

Interaction variants

Classic

Listening volume
40
Up next

Minimal

Listening volume
40
Up next

Soft

Listening volume
40
Up next

Hover, drag or use keyboard focus to compare the styles. Standard and enhanced levels work independently of each variant. Disabled controls keep their idle appearance.

Locale-aware number entry

Continuous and range sliders

40

Vertical sliders

Listening level
50%
0–100%
Marked level
50%
0–100%

One-time code

Entered 0 of 6 characters. This example sends nothing.

Grouped one-time code

Fractional, clearable rating

Clarity
How much did you enjoy it?
Rating: 3.5
Read the complete example
'use client';
import { Plus, Minus, Heart, MoveHorizontal } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { useId, useState } from 'react';
import { NumberField } from '@fayegram-ai/faye-ui/controls/number-field';
import { Slider } from '@fayegram-ai/faye-ui/controls/slider';
import { OTPField } from '@fayegram-ai/faye-ui/controls/otp-field';
import { Rating } from '@fayegram-ai/faye-ui/controls/rating';
import { Inline, Stack } from '@fayegram-ai/faye-ui/layout/layout';
import { Text } from '@fayegram-ai/faye-ui/core/text';
import InteractionVariants from './interaction-variants';
import { Section, Result } from '../example';
export default function Numeric() {
    const id = useId(), [rating, setRating] = useState(3.5), [code, setCode] = useState(''), [preview, setPreview] = useState<number | null>(null);
    return <><InteractionVariants/><Section title="Locale-aware number entry"><NumberField.Root id={`${id}-seats`} min={1} max={50} defaultValue={5}><NumberField.ScrubArea><label htmlFor={`${id}-seats`}>Seats</label><NumberField.ScrubAreaCursor><Icon icon={MoveHorizontal} size="sm"/></NumberField.ScrubAreaCursor></NumberField.ScrubArea><NumberField.Group><NumberField.Decrement aria-label="Remove a seat"><Icon icon={Minus} size="sm"/></NumberField.Decrement><NumberField.Input /><NumberField.Increment aria-label="Add a seat"><Icon icon={Plus} size="sm"/></NumberField.Increment></NumberField.Group></NumberField.Root></Section><Section title="Continuous and range sliders"><Slider.Root defaultValue={40} min={0} max={100}><Slider.Value /><Slider.Control><Slider.Track><Slider.Indicator /><Slider.Thumb aria-label="Volume"/></Slider.Track></Slider.Control></Slider.Root><Slider.Root defaultValue={[20, 70]} min={0} max={100}><Slider.Control><Slider.Track><Slider.Indicator /><Slider.Thumb index={0} aria-label="Minimum threshold"/><Slider.Thumb index={1} aria-label="Maximum threshold"/></Slider.Track></Slider.Control></Slider.Root></Section><Section title="Vertical sliders"><Inline gap="8" align="start" wrap>{([false,true] as const).map(marked=><Stack key={String(marked)} gap="3"><Text as="span" size="sm">{marked?'Marked level':'Listening level'}</Text><Slider.Root orientation="vertical" defaultValue={50} formatValue={value=>`${value}%`}><Slider.Value/><Slider.Control><Slider.Track><Slider.Indicator/></Slider.Track><Slider.Thumb aria-label={marked?'Marked vertical level':'Vertical listening level'}/>{marked&&<Slider.Marks marks={[{value:0,label:'0%'},{value:50,label:'50%'},{value:100,label:'100%'}]}/>}</Slider.Control></Slider.Root><Text as="span" size="sm" tone="muted">0–100%</Text></Stack>)}</Inline></Section><Section title="One-time code"><label htmlFor={`${id}-otp`}>Verification code</label><OTPField.Root id={`${id}-otp`} length={6} value={code} onValueChange={setCode}>{Array.from({ length: 6 }, (_, i) => <OTPField.Input key={i} aria-label={i === 0 ? undefined : `Character ${i + 1} of 6`}/>)}</OTPField.Root><Result>Entered {code.length} of 6 characters. This example sends nothing.</Result></Section><Section title="Grouped one-time code"><label htmlFor={`${id}-grouped-otp`}>Grouped verification code</label><OTPField.Root id={`${id}-grouped-otp`} length={6}>{Array.from({length:3},(_,i)=><OTPField.Input key={i} aria-label={i===0?undefined:`Grouped character ${i+1} of 6`}/>)}<OTPField.Separator aria-hidden="true">–</OTPField.Separator>{Array.from({length:3},(_,i)=><OTPField.Input key={i+3} aria-label={`Grouped character ${i+4} of 6`}/>)}</OTPField.Root></Section><Section title="Fractional, clearable rating"><Rating label="Clarity" value={rating} onValueChange={setRating} onPreviewChange={setPreview} precision={.5}/><Rating label="Read-only score" value={4.5} precision={.5} readOnly/><Rating label="How much did you enjoy it?" defaultValue={2} clearable={false} icon={<Icon icon={Heart} size="lg" fill="currentColor"/>} emptyIcon={<Icon icon={Heart} size="lg"/>}/><Result>Rating: {rating}{preview === null ? '' : ` · Preview: ${preview}`}</Result></Section></>;
}