"use client";
import { useState, type KeyboardEvent, type ReactNode } from "react";
export interface BillingOption {
id: string;
label: ReactNode;
/** A small word beside the label: what choosing it saves. */
note?: ReactNode;
}
export interface BillingSwitchProps {
options: BillingOption[];
/** The chosen option, when the page keeps it. */
value?: string;
/** The option chosen at first, when the switch keeps it itself. */
defaultValue?: string;
onChange?: (id: string) => void;
/** What the choice is, for a screen reader. */
"aria-label"?: string;
className?: string;
}
/**
* A choice between ways of paying, as one capsule. The chosen option is a
* surface that slides to wherever it is picked, so the switch reads as a
* single control and not as a row of buttons.
*/
export function BillingSwitch({ options, value, defaultValue, onChange, className, ...props }: BillingSwitchProps) {
const [own, setOwn] = useState(defaultValue ?? options[0]?.id);
const chosen = value ?? own;
const index = Math.max(0, options.findIndex((option) => option.id === chosen));
const pick = (id: string) => {
setOwn(id);
onChange?.(id);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = options[(index + step + options.length) % options.length];
if (!next) return;
pick(next.id);
event.currentTarget.querySelector<HTMLElement>(`[data-id="${next.id}"]`)?.focus();
};
return (
<div
role="radiogroup"
aria-label={props["aria-label"] ?? "Billing"}
data-slot="billing-switch"
onKeyDown={onKeyDown}
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
className={["relative inline-grid rounded-(--tui-billing-switch-radius) bg-(--tui-billing-switch-track) p-(--tui-billing-switch-padding) font-(family-name:--tui-font)", className].filter(Boolean).join(" ")}
>
<span
aria-hidden
data-slot="billing-switch-thumb"
style={{ width: `calc((100% - var(--tui-billing-switch-padding) * 2) / ${options.length})`, translate: `${index * 100}% 0` }}
className="absolute top-(--tui-billing-switch-padding) bottom-(--tui-billing-switch-padding) left-(--tui-billing-switch-padding) rounded-[calc(var(--tui-billing-switch-radius)_-_var(--tui-billing-switch-padding))] bg-(--tui-billing-switch-thumb) shadow-(--tui-billing-switch-thumb-shadow) transition-[translate] duration-(--tui-billing-switch-duration) ease-[cubic-bezier(0.3,1.2,0.5,1)]"
/>
{options.map((option) => {
const on = option.id === chosen;
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
data-id={option.id}
onClick={() => pick(option.id)}
className={`relative z-10 flex h-(--tui-billing-switch-height) cursor-default items-center justify-center gap-2 rounded-[calc(var(--tui-billing-switch-radius)_-_var(--tui-billing-switch-padding))] border-none bg-transparent px-(--tui-billing-switch-padding-x) font-[inherit] text-(length:--tui-billing-switch-font-size) leading-none font-medium whitespace-nowrap outline-none transition-colors duration-(--tui-billing-switch-duration) focus-visible:[outline:2px_solid_var(--tui-ring)] focus-visible:[outline-offset:2px] ${on ? "text-(--tui-billing-switch-fg)" : "text-(--tui-billing-switch-muted) hover:text-(--tui-billing-switch-fg)"}`}
>
{option.label}
{option.note && (
<span data-slot="billing-switch-note" className="rounded-full bg-(--tui-billing-switch-note-bg) px-[0.6em] py-[0.3em] text-[0.8em] leading-none font-semibold text-(--tui-billing-switch-note-fg) tabular-nums">
{option.note}
</span>
)}
</button>
);
})}
</div>
);
}:root {
color-scheme: light;
--tui-bg: #ffffff;
--tui-fg: #0a0a0a;
--tui-muted: #f5f5f5;
--tui-muted-fg: #737373;
--tui-border: #e5e5e5;
--tui-primary: #171717;
--tui-primary-fg: #fafafa;
--tui-ring: #a3a3a3;
--tui-success: #16a34a;
--tui-warning: #d97706;
--tui-danger: #dc2626;
--tui-radius: 8px;
--tui-border-width: 1px;
--tui-space: 4px;
--tui-shadow: none;
--tui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--tui-text: 14px;
--tui-duration: 150ms;
/* Billing switch */
--tui-billing-switch-height: calc(var(--tui-space) * 9);
--tui-billing-switch-padding: calc(var(--tui-space) * 1);
--tui-billing-switch-padding-x: calc(var(--tui-space) * 4.5);
--tui-billing-switch-radius: calc(var(--tui-radius) * 3.5);
--tui-billing-switch-font-size: var(--tui-text);
--tui-billing-switch-duration: 360ms;
--tui-billing-switch-track: color-mix(in srgb, var(--tui-bg) 94%, var(--tui-fg));
--tui-billing-switch-thumb: light-dark(var(--tui-bg), color-mix(in oklab, var(--tui-bg) 90%, var(--tui-fg)));
--tui-billing-switch-thumb-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-fg) 5%, transparent), 0 1px 3px rgb(0 0 0 / 0.08);
--tui-billing-switch-fg: var(--tui-fg);
--tui-billing-switch-muted: var(--tui-muted-fg);
--tui-billing-switch-note-bg: color-mix(in srgb, var(--tui-bg) 84%, var(--tui-success));
--tui-billing-switch-note-fg: var(--tui-success);
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
--tui-bg: #0a0a0a;
--tui-fg: #fafafa;
--tui-muted: #1c1c1c;
--tui-muted-fg: #a3a3a3;
--tui-border: #2a2a2a;
--tui-primary: #fafafa;
--tui-primary-fg: #171717;
--tui-ring: #737373;
--tui-success: #22c55e;
--tui-warning: #f59e0b;
--tui-danger: #ef4444;
}Needs Tailwind CSS v4. Import the stylesheet once, after Tailwind.