Billing period switcher
A monthly, quarterly or annual switch for a pricing section: native radios styled as one pill track, a saving note per option, and the chosen period's payment commitment beneath. It changes displayed prices only.
cmp_pricing_billing_toggle_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_pricing_billing_toggle_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-45ab1fda7e57989ca224ee73298e60206a39504e5df3526f12eeb312f3ec60ac
<!--
A billing-period switcher for a pricing section: a native radio group styled as one pill
track, with a filled thumb that slides under the chosen period and that period's payment
commitment set beneath it. It changes which display prices a parent shows, nothing else.
-->
<script module lang="ts">
export interface BillingPeriod {
/** Value bound to `value` and submitted with a form, e.g. "monthly". */
id: string;
/** Segment label, e.g. "Monthly". */
label: string;
/** Short saving note set inside the segment and read as part of its label, e.g. "Save 20%". */
note?: string;
/** What paying this way commits to, shown under the track while selected. */
commitment?: string;
/** Announced politely after a visitor picks this period, when `announce` is on. */
announcement?: string;
/** Shown but not selectable, e.g. monthly billing a plan does not offer. */
disabled?: boolean;
}
</script>
<script lang="ts">
interface Props {
/** Periods in display order, each with a unique id; two to four fit the track. */
periods: BillingPeriod[];
/** Selected period id. Bind it and pick each plan's price string from it. */
value: string;
/** Group label, e.g. "Billing period". */
label: string;
/** Called after a visitor picks a period, e.g. to update a checkout link's query. */
onChange?: (id: string) => void;
/** Form field name, so the choice submits with a native form. */
name?: string;
/** Hide the label visually; it stays the group's accessible name. */
hideLabel?: boolean;
/** Announce the picked period's announcement (or commitment) in a polite live region. */
announce?: boolean;
/** Line the label, track and commitment up on the start edge or centre them. */
align?: 'start' | 'center';
}
let {
periods,
value = $bindable(),
label,
onChange,
name,
hideLabel = false,
announce = false,
align = 'start'
}: Props = $props();
const uid = $props.id();
const group = $derived(name || `${uid}-period`);
const selectedIndex = $derived(periods.findIndex((period) => period.id === value));
const hasCommitment = $derived(periods.some((period) => period.commitment));
const hasNote = $derived(periods.some((period) => period.note));
/* Written only after a visitor's choice, so the region stays silent on load. */
let announcement = $state('');
function choose(period: BillingPeriod) {
value = period.id;
if (announce) announcement = period.announcement ?? period.commitment ?? period.label;
onChange?.(period.id);
}
</script>
{#if periods.length > 0}
<fieldset class="pricing-billing-toggle m-0 max-w-full min-w-0 border-0 p-0">
<legend
class={hideLabel
? 'sr-only'
: [
'float-start mb-2 w-full p-0 text-sm/5 font-medium text-balance text-[var(--_ink)]',
align === 'center' ? 'text-center' : 'text-start'
]}
>
{label}
</legend>
<div
class={[
'layout clear-both flex min-w-0 flex-col',
align === 'center' ? 'items-center' : 'items-start'
]}
>
<!-- Equal columns sized by the widest segment, so the thumb can travel by its own width.
Two rows: a name row and a note row, shared by every segment through subgrid. -->
<div
class="track relative inline-grid max-w-full auto-cols-fr grid-flow-col rounded-3xl p-1"
style:--_count={periods.length}
style:--_index={Math.max(selectedIndex, 0)}
data-count={periods.length}
data-notes={hasNote ? '' : undefined}
data-empty={selectedIndex < 0 ? '' : undefined}
>
<span class="thumb" aria-hidden="true"></span>
{#each periods as period, index (period.id)}
<!-- Content hangs from the top, and one line is 40 px (44 on touch screens) from its
line height alone, so names share a row however the notes fall. -->
<label
class="segment relative min-w-0 items-start justify-items-center rounded-[20px] px-2 py-2 text-center text-sm/6 font-medium sm:px-4 pointer-coarse:leading-7"
>
<input
type="radio"
class="absolute inset-0 m-0 cursor-[inherit] appearance-none rounded-[inherit] opacity-0"
name={group}
value={period.id}
checked={index === selectedIndex}
defaultChecked={index === selectedIndex}
disabled={period.disabled}
aria-describedby={period.commitment ? `${uid}-commitment-${index}` : undefined}
onchange={() => choose(period)}
/>
<span
class="content flex min-w-0 flex-wrap items-center justify-center gap-x-2 gap-y-1"
>
<span class="min-w-0 break-words hyphens-auto">{period.label}</span>
{#if period.note}
<span
class="note max-w-full rounded-full px-2 py-0.5 text-xs/4 break-words tabular-nums"
>{period.note}</span
>
{/if}
</span>
</label>
{/each}
</div>
{#if hasCommitment}
<!-- Every commitment shares one grid cell, so the line holds the height of the longest
and nothing below it moves when the period changes. -->
<div
class={[
'mt-2 grid max-w-[30em] text-[13px]/5 text-[var(--_muted)] tabular-nums',
align === 'center' ? 'text-center text-balance' : 'text-start text-pretty'
]}
>
{#each periods as period, index (period.id)}
<p
id="{uid}-commitment-{index}"
class={['col-start-1 row-start-1 m-0', index !== selectedIndex && 'invisible']}
>
{period.commitment ?? ''}
</p>
{/each}
</div>
{/if}
</div>
{#if announce}
<p class="sr-only" role="status" aria-live="polite">{announcement}</p>
{/if}
</fieldset>
{/if}
<style>
/* Public tokens: set --pricing-billing-toggle-* on the switcher or any ancestor to retone it. */
.pricing-billing-toggle {
--_accent: var(--pricing-billing-toggle-accent, #18181b);
--_on-accent: var(--pricing-billing-toggle-on-accent, #ffffff);
--_ink: var(--pricing-billing-toggle-ink, #18181b);
--_muted: var(--pricing-billing-toggle-muted, #52525b);
--_hairline: var(--pricing-billing-toggle-hairline, rgb(0 0 0 / 0.08));
--_dir: 1;
}
.pricing-billing-toggle:dir(rtl) {
--_dir: -1;
}
/*
* Radii are 24 px outside and 20 px inside rather than pills: a one-line track is 48 px tall,
* so it reads as a pill, and when narrow segments wrap to two lines the corners stay
* concentric instead of the thumb swelling into a circle.
*/
/* The track is one quiet object: a one-step fill mixed from the ink, and a hairline. */
.track {
background-color: color-mix(in oklab, var(--_ink) 4%, transparent);
box-shadow: inset 0 0 0 1px var(--_hairline);
}
/* The one elevation: the thumb, lit from above, one column wide, moved by its own width. */
.thumb {
position: absolute;
inset-block: 4px;
inset-inline-start: 4px;
width: calc((100% - 8px) / var(--_count));
border-radius: 20px;
background-color: var(--_accent);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.12),
0 1px 2px rgb(0 0 0 / 0.1),
0 2px 6px rgb(0 0 0 / 0.08);
translate: calc(var(--_index) * 100% * var(--_dir)) 0;
transition: translate 220ms cubic-bezier(0.2, 0, 0, 1);
}
.track[data-empty] .thumb {
opacity: 0;
}
/*
* Each segment and its content are subgrids of the track's two rows. Inline, the content is
* one wrapping row and the note row is empty. When the switcher is too narrow for its period
* count, every segment stacks at once: names on the first row, notes on the second, so a
* two-line name or a lone inline note can never put the notes on different lines.
*/
.layout {
container: billing-period / inline-size;
}
.track {
grid-template-rows: auto auto;
}
.segment {
display: grid;
grid-row: span 2;
grid-template-rows: subgrid;
grid-template-columns: minmax(0, 1fr);
}
.content {
grid-row: 1 / -1;
justify-self: stretch;
}
@container billing-period (width < 18rem) {
.track[data-count='2'][data-notes] {
width: 100%;
row-gap: 4px;
}
.track[data-count='2'][data-notes] .content {
display: grid;
grid-template-rows: subgrid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
justify-items: center;
}
}
@container billing-period (width < 40rem) {
.track[data-count='3'][data-notes] {
width: 100%;
row-gap: 4px;
}
.track[data-count='3'][data-notes] .content {
display: grid;
grid-template-rows: subgrid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
justify-items: center;
}
}
@container billing-period (width < 48rem) {
.track[data-count='4'][data-notes] {
width: 100%;
row-gap: 4px;
}
.track[data-count='4'][data-notes] .content {
display: grid;
grid-template-rows: subgrid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
justify-items: center;
}
}
.segment {
cursor: pointer;
color: var(--_muted);
transition-property: color, background-color;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.segment:has(:checked) {
color: var(--_on-accent);
}
/* Hover only on a segment a click would change. */
@media (hover: hover) {
.segment:hover:not(:has(:checked, :disabled)) {
color: var(--_ink);
background-color: color-mix(in oklab, var(--_ink) 5%, transparent);
}
}
.segment:has(:disabled) {
cursor: not-allowed;
opacity: 0.5;
}
.segment:has(:focus-visible) {
outline: 2px solid var(--_accent);
outline-offset: 2px;
}
.content {
transition: scale 80ms cubic-bezier(0.2, 0, 0, 1);
}
.segment:active:not(:has(:checked, :disabled)) {
background-color: color-mix(in oklab, var(--_ink) 9%, transparent);
transition-duration: 80ms;
}
.segment:active:not(:has(:disabled)) .content {
scale: 0.97;
}
/* The note's ring follows the segment's text, so it reads on the track and on the thumb. */
.note {
box-shadow: inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent);
}
/* Forced colours drop fills and shadows: outline the track and paint the thumb as Highlight. */
@media (forced-colors: active) {
.track {
outline: 1px solid CanvasText;
}
.thumb {
forced-color-adjust: none;
background-color: Highlight;
}
.segment:has(:checked) {
forced-color-adjust: none;
color: HighlightText;
}
.segment:has(:disabled) {
color: GrayText;
}
.segment:has(:focus-visible) {
outline-color: CanvasText;
}
}
@media (prefers-reduced-motion: reduce) {
.thumb {
transition: none;
}
.segment:active:not(:has(:disabled)) .content {
scale: 1;
}
}
</style>
Usage#
On this pageDisplay only. Pass the periods you offer and bind value; your pricing section reads it and chooses which price string each plan shows. It does not change the billing period at checkout, recalculate prices, or remember the choice: send value to your checkout yourself (through onChange, a link's query or the name field in a form).
- Suggested location
src/lib/components/pricing-billing-toggle-01
Limitations
- Display only: it does not change the billing period at checkout. Pass value to your checkout through
onChange, a link or the form field set by name. - It does not calculate prices or savings; every label, note and commitment is shown exactly as supplied.
- It does not persist the choice across pages or visits.
- The sliding thumb assumes equal segments and up to four periods; more than four will not fit a phone and are better as a select.
- The switcher fills the width of its parent and lays itself out from that width (a CSS container), so inside a flex row or another
shrink-to-fitparent give it a width or a flex basis; the track itself stays as wide as its segments. - A value that matches no period leaves every segment unselected and the commitment line empty.
- Period ids must be unique; a duplicate id makes the choice ambiguous and is an error in Svelte's development build.
- Resetting a surrounding form keeps the period currently shown, so the displayed prices and the submitted value stay in step; set value yourself if a reset should restore a different period.
- With an empty periods array nothing renders. With one period, the lone segment renders selected when value matches its id; most pages should hide the switcher instead.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.
Example
<!-- Illustrative content: replace the periods, prices and links with your own. -->
<script lang="ts">
import BillingPeriodSwitcher, {
type BillingPeriod
} from '$lib/components/pricing-billing-toggle-01/BillingPeriodSwitcher.svelte';
const periods: BillingPeriod[] = [
{ id: 'monthly', label: 'Monthly', commitment: 'Billed every month, per seat.' },
{
id: 'annual',
label: 'Annual',
note: 'Save 20%',
commitment: 'Billed once a year: $115.20 per seat.'
}
];
const prices: Record<string, string> = { monthly: '$12', annual: '$9.60' };
let period = $state('annual');
</script>
<BillingPeriodSwitcher label="Billing period" {periods} bind:value={period} align="center" announce />
<p>{prices[period]} per seat per month</p>
<a href="/signup?plan=team&billing={period}">Choose Team</a>Billing period switcher#
A segmented control for choosing how a pricing section is billed. It holds the choice and states what that choice commits the visitor to; your page decides what the choice does to the prices.
Wiring it to prices#
Keep one display string per plan per period in your own data and pick from the bound value:
<script lang="ts">
import BillingPeriodSwitcher from '$lib/components/pricing-billing-toggle-01/BillingPeriodSwitcher.svelte';
const periods = [
{ id: 'monthly', label: 'Monthly', commitment: 'Billed every month, per seat.' },
{
id: 'annual',
label: 'Annual',
note: 'Save 20%',
commitment: 'Billed once a year: $115.20 per seat.'
}
];
const team = { monthly: '$12', annual: '$9.60' };
let period = $state<'monthly' | 'annual'>('annual');
</script>
<BillingPeriodSwitcher
label="Billing period"
{periods}
bind:value={period}
align="center"
announce
/>
<p>{team[period]} per seat per month</p>The switcher never parses, converts or totals a price. The commitment line is your copy: say what the visitor pays and how often, not only the saving.
Checkout#
Changing the switch changes what is displayed, nothing else. Carry the value to checkout yourself:
- in a link:
href="/signup?plan=team&billing={period}"; - in a form: set
name="billing"and the selected period submits asbilling=annual; - anywhere else:
onChange={(id) => …}runs after each choice.
Your billing provider decides the real period and amount. If a plan cannot be bought on a period,
mark that period disabled and say why in its note.
Dark or tinted page#
.pricing-dark {
--pricing-billing-toggle-accent: #fafafa;
--pricing-billing-toggle-on-accent: #18181b;
--pricing-billing-toggle-ink: #fafafa;
--pricing-billing-toggle-muted: #a1a1aa;
--pricing-billing-toggle-hairline: rgb(255 255 255 / 0.1);
}The track's fill is mixed from the ink, so it follows the retone.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
periods | BillingPeriod[] | Yes | None | Periods in display order: { id, label, note?, commitment?, announcement?, disabled? }, each with a unique id. Two to four fit the track. |
value | string | Yes | None | Selected period id. $bindable: bind it and pick each plan's price string from it. |
label | string | Yes | None | Group label, e.g. "Billing period". Rendered as the fieldset legend. |
onChange | (id: string) => void | No | None | Called after a visitor picks a period, e.g. to update a checkout link's query string. |
name | string | No | None | Form field name, so the choice submits with a native form as name=id. When omitted or empty, the radios are grouped by a per-instance name. |
hideLabel | boolean | No | false | Visually hide the label; it remains the group's accessible name. |
announce | boolean | No | false | After a visitor picks a period, announce its announcement (or commitment, or label) in a polite live region. |
align | 'start' | 'center' | No | 'start' | Line the label, track and commitment up on the start edge, or centre them above a card grid. |
Customization#
On this pageChange periods, notes and commitments through props, retone the switcher through five CSS variables (accent, on-accent, ink, muted, hairline), and edit Tailwind classes in the source for size or spacing.
- Accent:
--pricing-billing-toggle-accentfills the thumb and draws the focus ring;--pricing-billing-toggle-on-accentis the text on the thumb. Keep the pair at 4.5:1 or better. - Text:
--pricing-billing-toggle-inksets the label and hovered segments;--pricing-billing-toggle-mutedsets unselected segments and the commitment line. Keep muted at 4.5:1 against the page. - Track:
--pricing-billing-toggle-hairlineoutlines the track. Its fill is mixed from the ink at 4%, so it follows a retone. - Dark page retone: accent
#fafafa, on-accent#18181b, ink#fafafa, muted#a1a1aa, hairline rgb(255 255 255 / 0.1) (all--pricing-billing-toggle-*).usage.mdhas the snippet. - Prices: keep one price string per plan per period in your own data and choose it from the bound value; the switcher never touches price text.
- Checkout: carry value into your checkout link or form (
onChange, a query string, or name inside a <form>); the switcher alone changes nothing at checkout. - Commitment copy: state what the visitor pays and how often ("Billed once a year: $115.20 per seat"), not only the saving.
- Placement: align="center" above a symmetric card grid; the default start alignment beside a plan heading or inside a form.
hideLabelsuits a compact placement where the heading already says what the choice is. - Periods: two or three is the usual; the track holds four. Mark a period disabled rather than dropping it when a plan does not offer it, and say why in its note.
Public CSS variables
| Variable | Token |
|---|---|
--pricing-billing-toggle-accent | accent |
--pricing-billing-toggle-on-accent | onAccent |
--pricing-billing-toggle-ink | ink |
--pricing-billing-toggle-muted | muted |
--pricing-billing-toggle-hairline | hairline |
Accessibility#
On this page- A fieldset with a legend holding native radio inputs (APG Radio Group). Tab enters the group at the selected period, arrow keys move and select, and disabled periods are skipped, all by the browser.
- It is not a
role=switch: there may be more than two periods, and each is a mutually exclusive choice. - The saving note is inside each option's label, so it is read with the period's name. Each radio is described by its own commitment text.
- With announce set, a polite live region states the picked period's announcement after a visitor changes it, and stays silent on load. Place your prices so a sighted visitor sees them change; the region is for everyone else.
- The selected segment is marked by the filled thumb and its text colour, not by colour alone on text.
- Under forced colours the track is outlined and the selected segment is painted in the system Highlight colours, so the choice stays visible.
- Segments show a two-pixel accent focus outline, offset by two pixels, on :focus-visible only, following the pill shape.
- Segments are at least 40 px tall, and 44 px on coarse pointers.
- White (
#ffffff) on the neutral accent (#18181b) measures about 17.7:1 and on the blue accent (#1d4ed8) about 6.7:1. Muted text (#52525b) measures 7.7:1 on white and about 7.1:1 on the track fill. - The thumb slides in 220 ms and stops moving under prefers-reduced-motion; the press scale is removed there too.
- Logical properties throughout, so the track and the thumb's travel mirror under dir="rtl".
- Element IDs come from
$props.id(), so several switchers on one page stay unique.
Known limitations
- A disabled period cannot take focus, so a screen reader user moving with arrow keys does not hear its note; say why it is unavailable in nearby text as well.
- Contrast ratios are computed for the shipped palettes only; re-check any changed token (4.5:1 for text, 3:1 for the focus ring).
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.