component — conform
Conform Slider
The quebi Slider wired to Conform. Puts the field name on the thumb, where react-aria renders the real range input, and supports single and range values.
- form
- conform
- slider
- range
- number
- validation
02 examples
Bound to a Conform form
Drag below 10 and submit to see the validation error wired from field metadata.
Range
Two thumbs under one name, parsed as an array.
source
conform-slider.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import type { FieldMetadata } from "@conform-to/react"
import { composeRenderProps } from "react-aria-components"
import type { SliderProps } from "react-aria-components"
import { cn } from "@/lib/utils"
import { describedBy, Description, FieldError, fieldStackStyles, Label } from "@/components/field"
import { Slider, SliderFill, SliderOutput, SliderThumb, SliderTrack } from "@/components/slider"
export interface ConformSliderProps
extends Omit<SliderProps, "name" | "form" | "value" | "defaultValue" | "onChange"> {
/**
* A slider bound to a numeric form value. Conform sees the raw wire value, so
* the metadata carries `number | string`; a range slider submits its name
* twice and surfaces as `string[]`.
*/
field: FieldMetadata<number | string | number[] | string[]>
label?: string
description?: string
/** Render two thumbs and submit a low/high pair under the same name. */
isRange?: boolean
/** Show the live value next to the label. */
showOutput?: boolean
}
/** Read the field's initial value as slider state, tolerating the wire's strings. */
function toDefaultValue(initialValue: unknown, isRange: boolean): number | number[] | undefined {
const toNumber = (raw: unknown) => {
const parsed = Number.parseFloat(String(raw))
return Number.isNaN(parsed) ? undefined : parsed
}
if (Array.isArray(initialValue)) {
const values = initialValue.map(toNumber).filter((n): n is number => n !== undefined)
return values.length > 0 ? values : undefined
}
const single = initialValue === undefined || initialValue === "" ? undefined : toNumber(initialValue)
if (single === undefined) return undefined
return isRange ? [single, single] : single
}
/**
* ConformSlider — Slider wired to Conform.
*
* Binds a numeric Conform field to the quebi Slider: derives name, form,
* default, and the described-by wiring from the field metadata and renders
* inline errors.
*
* Two things worth knowing about this control:
*
* 1. **The name goes on the thumb, not on the slider.** Each SliderThumb is
* what renders a real `<input type="range">`; a `name` on Slider itself goes
* nowhere. A range slider therefore submits two inputs under the same name,
* which is why the parsed value is an array.
* 2. react-aria's Slider has no `isRequired`/`isInvalid` and provides no
* FieldErrorContext — a slider always has a value, so there is nothing to
* require. Invalidity is announced through `aria-describedby` and the
* message below.
*/
export function ConformSlider({
field,
label,
description,
isRange = false,
showOutput = true,
className,
...props
}: ConformSliderProps) {
const hasErrors = !field.valid && !!field.errors
const thumbProps = {
name: field.name,
form: field.formId,
"aria-describedby": describedBy(
hasErrors && field.errorId,
description && field.descriptionId,
),
}
return (
<Slider
{...props}
defaultValue={toDefaultValue(field.initialValue, isRange)}
className={composeRenderProps(className, (resolved) =>
cn(
// The stack's margins space this field, so `Slider`'s own 8px gap
// steps aside rather than adding to them. Only the stack, not
// `fieldStyles`: the width is `Slider`'s to decide, because a
// vertical one is `w-fit` and `h-48`.
"orientation-horizontal:gap-y-0 orientation-vertical:gap-y-0",
fieldStackStyles,
resolved,
),
)}
>
{/* `data-slot="label"` on the row rather than on the `Label` inside it:
the label and the value read as one line, and it is the line the
field stack spaces and `FieldRow` puts on its label row. */}
{(label || showOutput) && (
<div
data-slot="label"
className={cn("flex items-center", label ? "justify-between" : "justify-end")}
>
{label && <Label className={cn(hasErrors && "text-quebi-danger")}>{label}</Label>}
{showOutput && <SliderOutput />}
</div>
)}
<SliderTrack>
<SliderFill />
{isRange ? (
<>
<SliderThumb index={0} {...thumbProps} />
<SliderThumb index={1} {...thumbProps} />
</>
) : (
<SliderThumb {...thumbProps} />
)}
</SliderTrack>
{/* These ids are ours to set: react-aria's Slider provides no
description or error slot wiring (see above), so rendering these
inside it generates nothing — the thumbs' aria-describedby is their
only reference. */}
{description && <Description id={field.descriptionId}>{description}</Description>}
{hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
</Slider>
)
}