Component
Billing toggle
A switch for billing periods such as monthly and yearly, with discount badges.
- #arc
- #component
- #pricing
- #switch
- #subscription
- #segmented
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/billing-toggle.jsonFiles
components/arc/billing-toggle/billing-toggle.module.csscomponents/arc/billing-toggle/billing-toggle.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownbilling-toggle.module.cssbilling-toggle.module.cssbilling-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.tsxbilling-toggle.tsxbilling-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
- Animated counterComponent
A number that counts up smoothly when it scrolls into view.