Quote request
A slider-driven live estimate, shown as a range with an explicit caveat.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Slider } from '@/components/ui/slider'
import { DateField } from '@/components/ui/date-field'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Quote request
*
* A slider drives a live estimate that updates as the user moves it. The
* estimate is deliberately shown as a range with a caveat — a form that
* commits to a price it cannot honour damages the conversation it was meant to
* start.
*/
const addOns = [
{ id: 'audit', label: 'Accessibility audit', price: 4800 },
{ id: 'migration', label: 'Migration support', price: 7200 },
{ id: 'training', label: 'Team training', price: 3200 },
]
export default function QuoteRequestForm() {
const form = useDemoForm({
schema: {
company: { validators: [required('Company')] },
email: { validators: [required('Email'), email()] },
seats: { initial: '40' },
startDate: {},
addOns: { initial: '' },
notes: {},
},
})
const seats = Number(form.values.seats || 0)
const selected = new Set((form.values.addOns ?? '').split(',').filter(Boolean))
const addOnTotal = addOns
.filter((item) => selected.has(item.id))
.reduce((total, item) => total + item.price, 0)
const base = seats * 180
const low = base + addOnTotal
const high = Math.round(low * 1.25)
const toggle = (id: string, isChecked: boolean) => {
const next = new Set(selected)
if (isChecked) next.add(id)
else next.delete(id)
form.setValue('addOns', Array.from(next).join(','))
}
return (
<FormShell
title="Request a quote"
description="The estimate updates as you change the inputs."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Request quote"
submittingLabel="Sending request"
onReset={form.reset}
successTitle="Quote requested"
width="lg"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="company" label="Company" required error={form.error('company')}>
{(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
</Field>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('email')} />
)}
</Field>
</div>
<Field name="seats" label="Seats" hint="Annual licence, billed per seat.">
{(field) => (
<Slider
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
min={5}
max={500}
step={5}
value={seats}
onValueChange={(value) => form.setValue('seats', String(value))}
format={(value) => String(value)}
/>
)}
</Field>
<Fieldset legend="Add-ons" name="addOns">
{addOns.map((item) => (
<Checkbox
key={item.id}
id={`addon-${item.id}`}
name="addOns"
value={item.id}
label={item.label}
description={`$${item.price.toLocaleString('en-US')} one-off`}
checked={selected.has(item.id)}
onChange={(event) => toggle(item.id, event.target.checked)}
/>
))}
</Fieldset>
<Field name="startDate" label="Preferred start" showOptional>
{(field) => <DateField {...field} {...form.field('startDate')} />}
</Field>
<Alert tone="info" title="Estimated annual cost">
<p aria-live="polite">
<span className="font-mono font-semibold text-ink-strong">
${low.toLocaleString('en-US')} – ${high.toLocaleString('en-US')}
</span>{' '}
for {seats} seats. Indicative only — the final quote depends on contract length and
support level.
</p>
</Alert>
<Field name="notes" label="Anything else?" showOptional>
{(field) => <Textarea {...field} rows={4} {...form.field('notes')} />}
</Field>
</FormShell>
)
}
components/blocks/forms/quote-request.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Slider } from '@/components/ui/slider'
import { DateField } from '@/components/ui/date-field'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Quote request
*
* A slider drives a live estimate that updates as the user moves it. The
* estimate is deliberately shown as a range with a caveat — a form that
* commits to a price it cannot honour damages the conversation it was meant to
* start.
*/
const addOns = [
{ id: 'audit', label: 'Accessibility audit', price: 4800 },
{ id: 'migration', label: 'Migration support', price: 7200 },
{ id: 'training', label: 'Team training', price: 3200 },
]
export default function QuoteRequestForm() {
const form = useDemoForm({
schema: {
company: { validators: [required('Company')] },
email: { validators: [required('Email'), email()] },
seats: { initial: '40' },
startDate: {},
addOns: { initial: '' },
notes: {},
},
})
const seats = Number(form.values.seats || 0)
const selected = new Set((form.values.addOns ?? '').split(',').filter(Boolean))
const addOnTotal = addOns
.filter((item) => selected.has(item.id))
.reduce((total, item) => total + item.price, 0)
const base = seats * 180
const low = base + addOnTotal
const high = Math.round(low * 1.25)
const toggle = (id: string, isChecked: boolean) => {
const next = new Set(selected)
if (isChecked) next.add(id)
else next.delete(id)
form.setValue('addOns', Array.from(next).join(','))
}
return (
<FormShell
title="Request a quote"
description="The estimate updates as you change the inputs."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Request quote"
submittingLabel="Sending request"
onReset={form.reset}
successTitle="Quote requested"
width="lg"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="company" label="Company" required error={form.error('company')}>
{(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
</Field>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('email')} />
)}
</Field>
</div>
<Field name="seats" label="Seats" hint="Annual licence, billed per seat.">
{(field) => (
<Slider
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
min={5}
max={500}
step={5}
value={seats}
onValueChange={(value) => form.setValue('seats', String(value))}
format={(value) => String(value)}
/>
)}
</Field>
<Fieldset legend="Add-ons" name="addOns">
{addOns.map((item) => (
<Checkbox
key={item.id}
id={`addon-${item.id}`}
name="addOns"
value={item.id}
label={item.label}
description={`$${item.price.toLocaleString('en-US')} one-off`}
checked={selected.has(item.id)}
onChange={(event) => toggle(item.id, event.target.checked)}
/>
))}
</Fieldset>
<Field name="startDate" label="Preferred start" showOptional>
{(field) => <DateField {...field} {...form.field('startDate')} />}
</Field>
<Alert tone="info" title="Estimated annual cost">
<p aria-live="polite">
<span className="font-mono font-semibold text-ink-strong">
${low.toLocaleString('en-US')} – ${high.toLocaleString('en-US')}
</span>{' '}
for {seats} seats. Indicative only — the final quote depends on contract length and
support level.
</p>
</Alert>
<Field name="notes" label="Anything else?" showOptional>
{(field) => <Textarea {...field} rows={4} {...form.field('notes')} />}
</Field>
</FormShell>
)
}
components/ui/slider.tsx
'use client'
import { useState, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
/**
* Slider
*
* A native `range` input with a token-styled track and thumb. Native means
* arrow keys, Home/End, Page Up/Down and `aria-valuetext` all work without a
* single key handler of our own.
*
* The filled portion of the track is painted with a gradient driven by the
* current value, which avoids a second absolutely-positioned element that
* would need to stay in sync.
*/
export interface SliderProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'defaultValue'> {
min?: number
max?: number
step?: number
defaultValue?: number
value?: number
/** Renders the live value on the trailing edge. */
showValue?: boolean
/** Formats the displayed value and `aria-valuetext`. */
format?: (value: number) => string
onValueChange?: (value: number) => void
}
export function Slider({
min = 0,
max = 100,
step = 1,
defaultValue,
value: controlledValue,
showValue = true,
format = (v) => String(v),
onValueChange,
className,
disabled,
...props
}: SliderProps) {
const [uncontrolled, setUncontrolled] = useState(defaultValue ?? min)
const value = controlledValue ?? uncontrolled
const percent = max === min ? 0 : ((value - min) / (max - min)) * 100
return (
<div className="flex items-center gap-3">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
aria-valuetext={format(value)}
onChange={(event) => {
const next = Number(event.target.value)
if (controlledValue === undefined) setUncontrolled(next)
onValueChange?.(next)
}}
className={cn('foundry-slider', className)}
style={{
// The filled portion of the track is painted from this one variable,
// so fill and thumb can never disagree about the current value.
['--slider-fill' as string]: `${percent}%`,
}}
{...props}
/>
{showValue ? (
<span className="w-12 shrink-0 text-right font-mono text-xs text-ink-muted tabular-nums">
{format(value)}
</span>
) : null}
</div>
)
}
components/ui/alert.tsx
import type { ReactNode } from 'react'
import { Info, CheckCircle2, AlertTriangle, OctagonAlert } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Alert
*
* A block-level message attached to a region of the page. The icon is chosen
* from the tone, and `role` switches to `alert` for danger so screen readers
* interrupt — informational tones use the polite `status` role instead.
*/
export type AlertTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
export interface AlertProps {
tone?: AlertTone
title?: ReactNode
children?: ReactNode
/** Rendered under the body — usually one or two buttons. */
actions?: ReactNode
/** Slot for a dismiss control supplied by the caller. */
trailing?: ReactNode
className?: string
/** Force the live-region politeness rather than deriving it from tone. */
live?: 'off' | 'polite' | 'assertive'
}
const tones = {
info: { icon: Info, surface: 'bg-info-soft border-info-line', accent: 'text-info' },
success: {
icon: CheckCircle2,
surface: 'bg-success-soft border-success-line',
accent: 'text-success',
},
warning: {
icon: AlertTriangle,
surface: 'bg-warning-soft border-warning-line',
accent: 'text-warning',
},
danger: {
icon: OctagonAlert,
surface: 'bg-danger-soft border-danger-line',
accent: 'text-danger',
},
neutral: { icon: Info, surface: 'bg-surface-sunken border-line', accent: 'text-ink-muted' },
} as const
export function Alert({
tone = 'info',
title,
children,
actions,
trailing,
className,
live,
}: AlertProps) {
const entry = tones[tone]
const Icon = entry.icon
const politeness = live ?? (tone === 'danger' ? 'assertive' : 'polite')
return (
<div
role={tone === 'danger' ? 'alert' : 'status'}
aria-live={politeness === 'off' ? undefined : politeness}
className={cn('flex gap-3 rounded-md border p-3 text-sm', entry.surface, className)}
>
<Icon className={cn('mt-0.5 size-4 shrink-0', entry.accent)} aria-hidden="true" />
<div className="min-w-0 flex-1">
{title ? <p className="font-semibold text-ink-strong">{title}</p> : null}
{children ? <div className={cn('text-ink-muted', title && 'mt-1')}>{children}</div> : null}
{actions ? <div className="mt-3 flex flex-wrap gap-2">{actions}</div> : null}
</div>
{trailing ? <div className="shrink-0">{trailing}</div> : null}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The estimate updates as the slider moves. It is deliberately a range with a caveat — a form that commits to a price it cannot honour damages the conversation it was meant to start.
- The estimate is a polite live region, so changing seats is announced without stealing focus.
- Add-on prices are shown next to each option, so the total is explicable rather than magical.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Seat slider
- Add-on checkboxes
- Live estimate range
- Preferred start date
Accessibility
- Live estimate
- A polite live region on the figure, so it is announced after the slider settles.
- Slider value text
- `aria-valuetext` carries the formatted number.
- Add-on descriptions
- Prices are in the checkbox description, part of the accessible name.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsSales enquiry
A qualification form that asks only what changes the conversation — and deliberately omits budget.
starter4 variantsSlider
A range input with a token-painted track, live value read-out and custom value formatting.
starter4 variantsEnterprise hero
Two equal-weight actions and an assurance panel carrying compliance facts.
starter3 variants