Timace

Billing switch

components/ui/billing-switch.tsx
"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>
  );
}
styles/timace-ui.css
: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.