Skip to content

Usage pricing calculator

Two sliders, a live total and a visible breakdown.

CommerceadvancedFeaturedpricingcalculatorusageslider

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 { useState } from 'react'
import { Container } from '@/components/ui/layout'
import { Slider } from '@/components/ui/slider'
import { Field } from '@/components/ui/field'
import { ButtonLink } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'

/**
 * Usage-based pricing calculator
 *
 * Two sliders and a live total. Usage pricing is only honest when the customer
 * can find their own number before talking to anyone, so the calculation is
 * shown as a breakdown rather than a single figure.
 *
 * The total is a polite live region: announced after the slider settles, never
 * on every intermediate value.
 */
const SEAT_PRICE = 18
const BUILD_PRICE = 0.4
const INCLUDED_BUILDS = 500

export default function UsageCalculatorPricing() {
  const [seats, setSeats] = useState(25)
  const [builds, setBuilds] = useState(1500)

  const seatCost = seats * SEAT_PRICE
  const billableBuilds = Math.max(0, builds - INCLUDED_BUILDS)
  const buildCost = Math.round(billableBuilds * BUILD_PRICE)
  const total = seatCost + buildCost

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <div className="text-center">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Work out your number before you talk to anyone.
          </h2>
          <p className="mx-auto mt-3 max-w-lg text-md text-ink-muted">
            Seats are fixed. Builds are metered, with the first {INCLUDED_BUILDS} included every
            month.
          </p>
        </div>

        <div className="mt-10 grid gap-6 rounded-xl border border-line bg-surface p-6 sm:p-8 lg:grid-cols-[1.2fr_1fr] lg:gap-10">
          <div className="flex flex-col gap-6">
            <Field name="calc-seats" label="Seats" hint={`$${SEAT_PRICE} per seat, per month.`}>
              {(field) => (
                <Slider
                  id={field.id}
                  name={field.name}
                  aria-describedby={field['aria-describedby']}
                  min={1}
                  max={200}
                  value={seats}
                  onValueChange={setSeats}
                  format={(value) => String(value)}
                />
              )}
            </Field>

            <Field
              name="calc-builds"
              label="Builds per month"
              hint={`First ${INCLUDED_BUILDS} included, then $${BUILD_PRICE.toFixed(2)} each.`}
            >
              {(field) => (
                <Slider
                  id={field.id}
                  name={field.name}
                  aria-describedby={field['aria-describedby']}
                  min={0}
                  max={10000}
                  step={100}
                  value={builds}
                  onValueChange={setBuilds}
                  format={(value) => value.toLocaleString('en-US')}
                />
              )}
            </Field>
          </div>

          <div className="rounded-lg border border-line bg-surface-sunken p-5">
            <h3 className="label-caps text-ink-subtle">Estimated monthly cost</h3>
            <p
              aria-live="polite"
              className="display-type mt-2 text-4xl font-semibold text-ink-strong tabular-nums"
            >
              ${total.toLocaleString('en-US')}
            </p>
            <dl className="mt-5 flex flex-col gap-2 border-t border-line pt-4 text-sm">
              <div className="flex justify-between gap-3">
                <dt className="text-ink-muted">
                  {seats} seats × ${SEAT_PRICE}
                </dt>
                <dd className="font-mono text-ink tabular-nums">
                  ${seatCost.toLocaleString('en-US')}
                </dd>
              </div>
              <div className="flex justify-between gap-3">
                <dt className="text-ink-muted">
                  {billableBuilds.toLocaleString('en-US')} billable builds
                </dt>
                <dd className="font-mono text-ink tabular-nums">
                  ${buildCost.toLocaleString('en-US')}
                </dd>
              </div>
            </dl>
            {billableBuilds === 0 ? (
              <Badge tone="success" className="mt-4">
                Within the included allowance
              </Badge>
            ) : null}
            <ButtonLink href="/forms/quote-request" block className="mt-5">
              Request a quote
            </ButtonLink>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/pricing/usage-calculator.tsx

'use client'

import { useState } from 'react'
import { Container } from '@/components/ui/layout'
import { Slider } from '@/components/ui/slider'
import { Field } from '@/components/ui/field'
import { ButtonLink } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'

/**
 * Usage-based pricing calculator
 *
 * Two sliders and a live total. Usage pricing is only honest when the customer
 * can find their own number before talking to anyone, so the calculation is
 * shown as a breakdown rather than a single figure.
 *
 * The total is a polite live region: announced after the slider settles, never
 * on every intermediate value.
 */
const SEAT_PRICE = 18
const BUILD_PRICE = 0.4
const INCLUDED_BUILDS = 500

export default function UsageCalculatorPricing() {
  const [seats, setSeats] = useState(25)
  const [builds, setBuilds] = useState(1500)

  const seatCost = seats * SEAT_PRICE
  const billableBuilds = Math.max(0, builds - INCLUDED_BUILDS)
  const buildCost = Math.round(billableBuilds * BUILD_PRICE)
  const total = seatCost + buildCost

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow">
        <div className="text-center">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Work out your number before you talk to anyone.
          </h2>
          <p className="mx-auto mt-3 max-w-lg text-md text-ink-muted">
            Seats are fixed. Builds are metered, with the first {INCLUDED_BUILDS} included every
            month.
          </p>
        </div>

        <div className="mt-10 grid gap-6 rounded-xl border border-line bg-surface p-6 sm:p-8 lg:grid-cols-[1.2fr_1fr] lg:gap-10">
          <div className="flex flex-col gap-6">
            <Field name="calc-seats" label="Seats" hint={`$${SEAT_PRICE} per seat, per month.`}>
              {(field) => (
                <Slider
                  id={field.id}
                  name={field.name}
                  aria-describedby={field['aria-describedby']}
                  min={1}
                  max={200}
                  value={seats}
                  onValueChange={setSeats}
                  format={(value) => String(value)}
                />
              )}
            </Field>

            <Field
              name="calc-builds"
              label="Builds per month"
              hint={`First ${INCLUDED_BUILDS} included, then $${BUILD_PRICE.toFixed(2)} each.`}
            >
              {(field) => (
                <Slider
                  id={field.id}
                  name={field.name}
                  aria-describedby={field['aria-describedby']}
                  min={0}
                  max={10000}
                  step={100}
                  value={builds}
                  onValueChange={setBuilds}
                  format={(value) => value.toLocaleString('en-US')}
                />
              )}
            </Field>
          </div>

          <div className="rounded-lg border border-line bg-surface-sunken p-5">
            <h3 className="label-caps text-ink-subtle">Estimated monthly cost</h3>
            <p
              aria-live="polite"
              className="display-type mt-2 text-4xl font-semibold text-ink-strong tabular-nums"
            >
              ${total.toLocaleString('en-US')}
            </p>
            <dl className="mt-5 flex flex-col gap-2 border-t border-line pt-4 text-sm">
              <div className="flex justify-between gap-3">
                <dt className="text-ink-muted">
                  {seats} seats × ${SEAT_PRICE}
                </dt>
                <dd className="font-mono text-ink tabular-nums">
                  ${seatCost.toLocaleString('en-US')}
                </dd>
              </div>
              <div className="flex justify-between gap-3">
                <dt className="text-ink-muted">
                  {billableBuilds.toLocaleString('en-US')} billable builds
                </dt>
                <dd className="font-mono text-ink tabular-nums">
                  ${buildCost.toLocaleString('en-US')}
                </dd>
              </div>
            </dl>
            {billableBuilds === 0 ? (
              <Badge tone="success" className="mt-4">
                Within the included allowance
              </Badge>
            ) : null}
            <ButtonLink href="/forms/quote-request" block className="mt-5">
              Request a quote
            </ButtonLink>
          </div>
        </div>
      </Container>
    </section>
  )
}

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/field.tsx

import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Field
 *
 * The accessibility contract for every form control in Foundry lives here, in
 * one place, rather than being re-implemented per input.
 *
 * Ids are derived deterministically from `name` instead of `useId`, for two
 * reasons: the component stays renderable from a Server Component (no hooks),
 * and the markup is byte-identical between server and client, so a form works
 * before hydration — see `/docs/accessibility`.
 *
 * The render prop hands back the exact wiring a control needs, which makes it
 * impossible to forget `aria-describedby` on a field that has help text.
 */
export interface FieldRenderProps {
  id: string
  name: string
  'aria-describedby': string | undefined
  'aria-invalid': true | undefined
  'aria-required': true | undefined
  required: boolean
  invalid: boolean
}

export interface FieldProps {
  name: string
  label: ReactNode
  hint?: ReactNode
  /** Validation failure. Presence flips the field into its invalid state. */
  error?: string | null
  /** Confirmation message shown once a value has validated. */
  success?: string | null
  required?: boolean
  /** Marks the field "Optional" instead of marking required fields. */
  showOptional?: boolean
  /** Visually hides the label while keeping it available to screen readers. */
  hideLabel?: boolean
  className?: string
  /** Disambiguates ids when the same field name appears twice on one page. */
  idPrefix?: string
  children: (props: FieldRenderProps) => ReactNode
}

export function Field({
  name,
  label,
  hint,
  error,
  success,
  required = false,
  showOptional = false,
  hideLabel = false,
  className,
  idPrefix = 'field',
  children,
}: FieldProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const successId = success && !error ? `${id}-success` : undefined
  const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      <label
        htmlFor={id}
        className={cn(
          'flex items-baseline gap-1.5 text-sm font-medium text-ink',
          hideLabel && 'sr-only',
        )}
      >
        <span>{label}</span>
        {required && !showOptional ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
        {showOptional && !required ? (
          <span className="text-xs font-normal text-ink-subtle">Optional</span>
        ) : null}
      </label>

      {hint ? (
        <p id={hintId} className="text-xs leading-normal text-ink-muted">
          {hint}
        </p>
      ) : null}

      {children({
        id,
        name,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
        required,
        invalid: Boolean(error),
      })}

      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}

      {success && !error ? (
        <p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
          <CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{success}</span>
        </p>
      ) : null}
    </div>
  )
}

/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
  legend: ReactNode
  hint?: ReactNode
  error?: string | null
  name: string
  required?: boolean
  className?: string
  children: ReactNode
  idPrefix?: string
}

export function Fieldset({
  legend,
  hint,
  error,
  name,
  required = false,
  className,
  children,
  idPrefix = 'group',
}: FieldsetProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined

  return (
    <fieldset
      className={cn('flex min-w-0 flex-col gap-2', className)}
      aria-describedby={describedBy}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
    >
      <legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
        <span>{legend}</span>
        {required ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
      </legend>
      {hint ? (
        <p id={hintId} className="text-xs text-ink-muted">
          {hint}
        </p>
      ) : null}
      {children}
      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}
    </fieldset>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Usage pricing is only honest when the customer can find their own number before talking to anyone, so the calculation is shown as a breakdown rather than a single figure.

  • Show the included allowance explicitly, or the metered line looks like a surprise charge.
  • Announce the total politely — after the slider settles, never on every intermediate value.

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
  • Usage slider
  • Itemised breakdown

Accessibility

Live total
The figure sits in a polite live region.
Slider value text
Each slider announces its formatted value through aria-valuetext.

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.