Usage pricing calculator
Two sliders, a live total and a visible breakdown.
Live preview
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.
Related
All sectionsSlider
A range input with a token-painted track, live value read-out and custom value formatting.
starter4 variantsQuote request
A slider-driven live estimate, shown as a range with an explicit caveat.
intermediate4 variantsPricing with billing toggle
A two-state radio group for monthly and annual, with the saving stated in money.
intermediate3 variants