Skip to content

Quote request

A slider-driven live estimate, shown as a range with an explicit caveat.

Businessintermediatequotepricingestimateslider

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.

All forms