# 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.

- ID: `cmp_pricing_billing_toggle_01`
- Slug: `pricing-billing-toggle-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `pricing`
- Detail page: https://pagesugar.com/components/pricing-billing-toggle-01?variant=blue
- Preview: https://pagesugar.com/preview/pricing-billing-toggle-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-45ab1fda7e57989ca224ee73298e60206a39504e5df3526f12eeb312f3ec60ac` |
| `blue` | Blue accent | no | `sha256-f27f5d571ea0f93b83612203d3d5656b1a378e016e9d5af6ad86499ccce7a650` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/pricing-billing-toggle-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Display 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).

Required props: `periods`, `value`, `label`

```svelte
<!-- 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>
```

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-fit parent 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.

## Usage guide

### 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:

```svelte
<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 as `billing=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

```css
.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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `periods` | `BillingPeriod[]` | yes |  | Periods in display order: { id, label, note?, commitment?, announcement?, disabled? }, each with a unique id. Two to four fit the track. |
| `value` | `string` | yes |  | Selected period id. $bindable: bind it and pick each plan's price string from it. |
| `label` | `string` | yes |  | Group label, e.g. "Billing period". Rendered as the fieldset legend. |
| `onChange` | `(id: string) => void` | no |  | Called after a visitor picks a period, e.g. to update a checkout link's query string. |
| `name` | `string` | no |  | 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

Change 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-accent fills the thumb and draws the focus ring; --pricing-billing-toggle-on-accent is the text on the thumb. Keep the pair at 4.5:1 or better.
- Text: --pricing-billing-toggle-ink sets the label and hovered segments; --pricing-billing-toggle-muted sets unselected segments and the commitment line. Keep muted at 4.5:1 against the page.
- Track: --pricing-billing-toggle-hairline outlines 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.md has 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. hideLabel suits 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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--pricing-billing-toggle-accent` |
| `onAccent` | `--pricing-billing-toggle-on-accent` |
| `ink` | `--pricing-billing-toggle-ink` |
| `muted` | `--pricing-billing-toggle-muted` |
| `hairline` | `--pricing-billing-toggle-hairline` |

## Accessibility

- 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).

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Blue accent (`blue`)
- Entry: `BillingPeriodSwitcher.svelte`
- Suggested directory: `src/lib/components/pricing-billing-toggle-01`
- Files: 1
- Artifact digest: `sha256-f27f5d571ea0f93b83612203d3d5656b1a378e016e9d5af6ad86499ccce7a650`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `BillingPeriodSwitcher.svelte`

Role: entry · 10501 bytes · SHA-256 `a27e3fabc59dc7d1430c9e790dd160a8de703f71de270d4ccd00e9e4325af5ff`

```svelte
<!--
	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, #1d4ed8);
		--_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>
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-f27f5d571ea0f93b83612203d3d5656b1a378e016e9d5af6ad86499ccce7a650`
- Entry: `BillingPeriodSwitcher.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_pricing_billing_toggle_01/1.0.0/blue/sha256-f27f5d571ea0f93b83612203d3d5656b1a378e016e9d5af6ad86499ccce7a650/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_pricing_billing_toggle_01/1.0.0/blue/sha256-f27f5d571ea0f93b83612203d3d5656b1a378e016e9d5af6ad86499ccce7a650/bundle.zip (7170 bytes, sha256 `baf4b77cab7c9e8d8a88175c002f63ce92b5c4cdf45497275328e92b24b9b9a4`)

Files:

- `BillingPeriodSwitcher.svelte` (entry, 10501 bytes): https://pagesugar.com/artifacts/cmp_pricing_billing_toggle_01/1.0.0/blue/sha256-f27f5d571ea0f93b83612203d3d5656b1a378e016e9d5af6ad86499ccce7a650/source/BillingPeriodSwitcher.svelte
