Skip to content
Component

Billing toggle

A switch for billing periods such as monthly and yearly, with discount badges.

  • #arc
  • #component
  • #pricing
  • #switch
  • #subscription
  • #segmented

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/billing-toggle.json

billing-toggle.module.csscss
/* Override --billing-accent on any ancestor to recolor the savings note. */
.root {
  --_accent: var(--billing-accent, var(--success));
  --_h: 34px;
  --_pad: 3px;
  position: relative;
  isolation: isolate;
  display: inline-flex;
  max-width: 100%;
  align-items: center;
  padding: var(--_pad);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-pill);
  background: var(--surface-muted);
  font-family: var(--font-body);
  font-size: var(--text-sm);
  letter-spacing: var(--tracking-body);
  line-height: 1;
}
.root[data-size="lg"] { --_h: 42px; --_pad: 4px; font-size: var(--text-base); }

/* One thumb for the whole track. Its position and width are measured from the selected option. */
.thumb,
.thumbStatic {
  position: absolute;
  z-index: -1;
  border-radius: var(--radius-pill);
  background: var(--surface-raised);
  box-shadow: 0 0 0 1px var(--border-subtle), 0 1px 2px oklch(0% 0 0 / .06), 0 2px 6px oklch(0% 0 0 / .04);
  pointer-events: none;
}
.thumb { top: var(--_pad); bottom: var(--_pad); left: 0; will-change: transform, width; }
.thumbStatic { inset: 0; }
:global(:root[data-theme="dark"]) .thumb,
:global(:root[data-theme="dark"]) .thumbStatic {
  box-shadow: 0 0 0 1px var(--border), inset 0 1px 0 oklch(100% 0 0 / .05), 0 1px 2px oklch(0% 0 0 / .3);
}

.option {
  position: relative;
  display: inline-flex;
  min-width: 0;
  height: var(--_h);
  align-items: center;
  padding: 0 14px;
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  letter-spacing: inherit;
  white-space: nowrap;
  cursor: pointer;
  transition: color var(--duration-fast) var(--ease-standard);
  -webkit-tap-highlight-color: transparent;
}
.option[data-selected] { color: var(--foreground); cursor: default; }
.option:has(.badge) { padding-right: 5px; }
.root[data-size="lg"] .option { padding-inline: 18px; }
.root[data-size="lg"] .option:has(.badge) { padding-right: 6px; }

@media (hover: hover) and (pointer: fine) {
  .option:not([data-selected]):hover { color: var(--text-secondary); }
}

.content {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  transition: transform var(--duration-fast) var(--ease-standard);
}
.option:not([data-selected]):active .content { transform: scale(.97); }

.label { font-weight: 500; }

/* The savings note: quiet until its option is chosen, then it takes the accent tint. */
.badge {
  display: inline-flex;
  height: calc(var(--_h) - 10px);
  align-items: center;
  padding: 0 8px;
  border-radius: var(--radius-pill);
  background: color-mix(in oklab, var(--foreground) 6%, transparent);
  color: var(--text-secondary);
  font-size: var(--text-xs);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard);
}
.badge[data-active] {
  background: color-mix(in oklab, var(--_accent) 13%, transparent);
  color: color-mix(in oklab, var(--_accent) 88%, var(--foreground));
}
:global(:root[data-theme="dark"]) .badge[data-active] {
  background: color-mix(in oklab, var(--_accent) 18%, transparent);
  color: var(--_accent);
}

/* Stacked swap: every candidate reserves the cell, the visible one crossfades on top. */
.swap { position: relative; display: inline-grid; justify-items: center; overflow-y: clip; overflow-clip-margin: 2px; }
.swapSizer { grid-area: 1 / 1; visibility: hidden; }
.swapText { grid-area: 1 / 1; display: inline-block; }

@media (prefers-reduced-motion: reduce) {
  .content, .badge { transition: none; }
}

/* ------------------------------------------------------------------------ Price */

.price { display: inline-flex; min-width: 0; flex-wrap: wrap; align-items: flex-end; gap: 4px 10px; }
.amount {
  display: inline-flex;
  align-items: flex-start;
  color: var(--foreground);
  font-family: var(--font-display);
  font-size: var(--text-3xl);
  font-weight: 400;
  letter-spacing: var(--tracking-display);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.currency { margin-top: .12em; margin-right: .04em; color: var(--text-secondary); font-size: .55em; }
.counter { display: inline-flex; }
.counter > span { font: inherit !important; letter-spacing: inherit !important; }
.aside {
  display: inline-flex;
  align-items: baseline;
  padding-bottom: .3em;
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: var(--leading-body);
  white-space: nowrap;
}
.wasSlot { display: inline-block; overflow-x: clip; }
.was { display: inline-block; padding-right: 6px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-decoration-thickness: 1px; }
.period { position: relative; display: inline-block; overflow-x: clip; overflow-clip-margin: 2px; }
.periodSizer { display: inline-block; visibility: hidden; }
.periodText { position: absolute; top: 0; left: 0; display: inline-block; }
billing-toggle.tsxtsx
"use client";

import { useCallback, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, type Transition, type Variants } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { AnimatedCounter } from "../animated-counter/animated-counter";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./billing-toggle.module.css";
import { playUiSound } from "../sound/play";

export interface BillingToggleOption {
  value: string;
  label: string;
  /** Short savings note, e.g. "20% 할인". */
  badge?: string;
  /** Badge text once this option is selected, e.g. "You save $48". Defaults to `badge`. */
  activeBadge?: string;
}

export interface BillingToggleProps {
  value: string;
  onValueChange: (value: string) => void;
  options?: BillingToggleOption[];
  /** Accessible name of the group. */
  label?: string;
  size?: "md" | "lg";
  className?: string;
}

const { duration, ease, blur } = motionTokens;

/** Critically damped: the thumb glides and lands without overshoot. */
const glide: Transition = { type: "spring", visualDuration: motionTokens.spring.settle.visualDuration, bounce: motionTokens.spring.settle.bounce };

const DEFAULT_OPTIONS: BillingToggleOption[] = [
  { value: "monthly", label: "월간" },
  { value: "yearly", label: "연간", badge: "20% 할인" },
];

const swap: Variants = {
  hidden: { opacity: 0, y: "0.45em", filter: `blur(${blur.subtle}px)` },
  shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: [...ease.enter] } },
  gone: { opacity: 0, y: "-0.45em", filter: `blur(${blur.subtle}px)`, transition: { duration: duration.fast, ease: [...ease.standard] } },
};
const still: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: duration.fast } }, gone: { opacity: 0, transition: { duration: 0 } } };

/**
 * Text that swaps in place. Every candidate sits in the same grid cell, so the box always has the
 * width of the longest one and nothing around it moves when the text changes.
 */
function StableSwap({ text, candidates, reduced, className }: { text: string; candidates: string[]; reduced: boolean; className?: string }) {
  return (
    <span className={[styles.swap, className].filter(Boolean).join(" ")}>
      {[...new Set(candidates)].map(candidate => <span key={candidate} className={styles.swapSizer} aria-hidden="true">{candidate}</span>)}
      <AnimatePresence initial={false}>
        <motion.span key={text} className={styles.swapText} variants={reduced ? still : swap} initial="hidden" animate="shown" exit="gone">{text}</motion.span>
      </AnimatePresence>
    </span>
  );
}

type Thumb = { x: number; width: number };

/**
 * A billing period switch. One thumb glides between the options on a critically damped spring, and
 * the savings note on the cheaper period tints and rewrites itself in place once it is chosen.
 */
export function BillingToggle({ value, onValueChange, options = DEFAULT_OPTIONS, label = "결제 주기", size = "md", className }: BillingToggleProps) {
  const reduced = !!useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const refs = useRef<Array<HTMLButtonElement | null>>([]);
  const [thumb, setThumb] = useState<Thumb | null>(null);
  const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));

  const measure = useCallback(() => {
    const node = refs.current[selectedIndex];
    if (!node) return;
    setThumb(current => current && current.x === node.offsetLeft && current.width === node.offsetWidth ? current : { x: node.offsetLeft, width: node.offsetWidth });
  }, [selectedIndex]);

  useLayoutEffect(() => {
    measure();
    const root = rootRef.current;
    if (!root || typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(measure);
    observer.observe(root);
    return () => observer.disconnect();
  }, [measure]);

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
    const target = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : step ? (index + step + options.length) % options.length : -1;
    if (target < 0) return;
    event.preventDefault();
    if (target !== selectedIndex) playUiSound("select");
    onValueChange(options[target]!.value);
    refs.current[target]?.focus();
  };

  return (
    <div ref={rootRef} role="radiogroup" aria-label={label} className={[styles.root, className].filter(Boolean).join(" ")} data-size={size}>
      {thumb ? (
        <motion.span
          className={styles.thumb}
          aria-hidden="true"
          initial={false}
          animate={{ x: thumb.x, width: thumb.width }}
          transition={reduced ? { duration: 0 } : glide}
        />
      ) : null}
      {options.map((option, index) => {
        const selected = index === selectedIndex;
        const badgeText = selected ? option.activeBadge ?? option.badge : option.badge;
        const candidates = [option.badge, option.activeBadge].filter((text): text is string => !!text);
        return (
          <button
            key={option.value}
            ref={node => { refs.current[index] = node; }}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            className={styles.option}
            data-selected={selected || undefined}
            onClick={() => { if (!selected) playUiSound("select"); onValueChange(option.value); }}
            onKeyDown={event => onKeyDown(event, index)}
          >
            {selected && !thumb ? <span className={styles.thumbStatic} aria-hidden="true" /> : null}
            <span className={styles.content}>
              <span className={styles.label}>{option.label}</span>
              {badgeText ? (
                <span className={styles.badge} data-active={selected || undefined}>
                  <StableSwap text={badgeText} candidates={candidates} reduced={reduced} />
                </span>
              ) : null}
            </span>
          </button>
        );
      })}
    </div>
  );
}

export interface BillingPriceProps {
  amount: number;
  currency?: string;
  /** Period after the price, e.g. "/ 월". Swaps in place when it changes. */
  period?: string;
  /** Previous price, shown struck through when it is higher than `amount`. */
  was?: number;
  decimals?: number;
  className?: string;
}

const clip: Transition = { type: "spring", visualDuration: motionTokens.spring.settle.visualDuration, bounce: motionTokens.spring.settle.bounce };

/** A price that rolls to its new amount. The old price and the period ease their width, so nothing beside them jumps. */
export function BillingPrice({ amount, currency = "₩", period, was, decimals = 0, className }: BillingPriceProps) {
  const reduced = !!useReducedMotion();
  const showWas = was !== undefined && was > amount;
  const periodRef = useRef<HTMLSpanElement>(null);
  const [periodWidth, setPeriodWidth] = useState<number | null>(null);

  useLayoutEffect(() => {
    const sizer = periodRef.current;
    if (sizer) setPeriodWidth(sizer.offsetWidth);
  }, [period]);

  return (
    <span className={[styles.price, className].filter(Boolean).join(" ")}>
      <span className={styles.amount}>
        <span className={styles.currency}>{currency}</span>
        <span className={styles.counter}><AnimatedCounter value={amount} decimals={decimals} /></span>
      </span>
      <span className={styles.aside}>
        <AnimatePresence initial={false}>
          {showWas ? (
            <motion.span
              key="was"
              className={styles.wasSlot}
              initial={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}
              animate={{ opacity: 1, width: "auto" }}
              exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, width: 0, transition: { ...clip, opacity: { duration: duration.fast } } }}
              transition={reduced ? { duration: duration.fast } : { ...clip, opacity: { duration: duration.standard, ease: [...ease.enter] } }}
            >
              <del className={styles.was}>{currency}{was.toLocaleString("ko-KR", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}</del>
            </motion.span>
          ) : null}
        </AnimatePresence>
        {period ? (
          <motion.span
            className={styles.period}
            initial={false}
            animate={periodWidth === null ? undefined : { width: periodWidth }}
            transition={reduced ? { duration: 0 } : clip}
          >
            <span ref={periodRef} className={styles.periodSizer} aria-hidden="true">{period}</span>
            <AnimatePresence initial={false}>
              <motion.span key={period} className={styles.periodText} variants={reduced ? still : swap} initial="hidden" animate="shown" exit="gone">{period}</motion.span>
            </AnimatePresence>
          </motion.span>
        ) : null}
      </span>
    </span>
  );
}

export default BillingToggle;

Used items