{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pricing-toggle-switch","type":"registry:component","title":"Pricing Toggle Switch","description":"A monthly/yearly billing switch with a springy, draggable thumb and a savings badge. Prices roll digit by digit like an odometer, and an optional plans prop turns it into a three-card pricing table with a border beam on the recommended plan. No dependencies.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/pricing-toggle-switch.tsx","content":"\"use client\";\n\nimport { useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from \"react\";\n\nexport type PricingPlan = {\n  name: string;\n  monthlyPrice: number;\n  yearlyPrice: number;\n  description?: string;\n  features?: string[];\n  highlighted?: boolean;\n  badge?: string;\n  cta?: string;\n};\n\nconst SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\\([\\d\\s.,%/-]+\\))$/i;\n\nconst CSS = [\n  \"@property --ptsw-a{syntax:'<angle>';inherits:false;initial-value:0deg}\",\n  \".ptsw{--ptsw-spring:cubic-bezier(.34,1.56,.64,1);--ptsw-out:cubic-bezier(.2,.8,.2,1);box-sizing:border-box;display:flex;flex-direction:column;align-items:center;gap:20px;color:inherit;font-variant-numeric:tabular-nums}\",\n  \".ptsw *,.ptsw *::before,.ptsw *::after{box-sizing:border-box}\",\n  \".ptsw[data-single]{padding:32px;border-radius:16px;box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 14%,transparent)}\",\n  \".ptsw[data-plans]{width:100%;gap:28px;container-type:inline-size}\",\n  \".ptsw-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\",\n  /* toggle row */\n  \".ptsw-row{display:flex;align-items:center;gap:12px}\",\n  \".ptsw-lab{display:inline-flex;align-items:center;gap:8px;font-size:.875rem;font-weight:500;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none}\",\n  \".ptsw-lt{opacity:.5;transition:opacity .3s ease}\",\n  \".ptsw-lab[data-on] .ptsw-lt{opacity:1}\",\n  \".ptsw-badge{display:inline-block;padding:4px 8px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;white-space:nowrap;color:var(--ptsw-c);background:color-mix(in srgb,var(--ptsw-c) 14%,transparent);transition:background-color .3s ease,color .3s ease,transform .5s var(--ptsw-spring)}\",\n  \".ptsw-lab[data-on] .ptsw-badge{color:#fff;background:var(--ptsw-c);transform:scale(1.05)}\",\n  /* switch */\n  \".ptsw-sw{position:relative;flex:none;width:56px;height:32px;margin:0;padding:0;border:0;border-radius:999px;cursor:pointer;color:inherit;background:color-mix(in srgb,currentColor 18%,transparent);box-shadow:inset 0 1px 2px rgba(0,0,0,.22),inset 0 0 0 1px color-mix(in srgb,currentColor 12%,transparent);-webkit-tap-highlight-color:transparent;touch-action:pan-y}\",\n  \".ptsw-sw::before{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,color-mix(in srgb,var(--ptsw-c) 82%,#fff),var(--ptsw-c));box-shadow:0 6px 18px -6px var(--ptsw-c),inset 0 1px 0 rgba(255,255,255,.28);opacity:0;transition:opacity .35s ease}\",\n  \".ptsw-sw::after{content:'';position:absolute;inset:-6px -4px}\",\n  \".ptsw-sw[aria-checked=true]::before{opacity:1}\",\n  \".ptsw-th{position:absolute;left:4px;top:4px;width:var(--w,24px);height:24px;border-radius:999px;background:#fff;box-shadow:0 2px 6px rgba(0,0,0,.35),0 0 0 .5px rgba(0,0,0,.12);transition:transform .55s var(--ptsw-spring),width .3s var(--ptsw-spring)}\",\n  \".ptsw-sw[aria-checked=true] .ptsw-th{transform:translateX(calc(48px - var(--w,24px)))}\",\n  \".ptsw-sw:active{--w:31px}\",\n  \".ptsw-sw:focus-visible,.ptsw-cta:focus-visible{outline:2px solid var(--ptsw-c)!important;outline-offset:3px!important}\",\n  \".ptsw-sw:focus-visible{border-radius:999px}\",\n  /* rolling price */\n  \".ptsw-price{display:flex;align-items:flex-start;font-size:var(--ptsw-fs,2.25rem);font-weight:800;letter-spacing:-.035em;line-height:1.15}\",\n  \".ptsw-cur{margin:.12em .08em 0 0;font-size:.58em;font-weight:700;opacity:.55;line-height:1}\",\n  \".ptsw-num{display:inline-flex;height:1.15em}\",\n  \".ptsw-d{position:relative;display:block;overflow:hidden;height:1.15em;width:.62em;text-align:center;transition:width .55s var(--ptsw-out),opacity .4s ease;-webkit-mask-image:linear-gradient(transparent,#000 12%,#000 88%,transparent);mask-image:linear-gradient(transparent,#000 12%,#000 88%,transparent)}\",\n  \".ptsw-d[data-s]{width:.3em;-webkit-mask-image:none;mask-image:none;line-height:1.15}\",\n  \".ptsw-d[data-e]{width:0;opacity:0}\",\n  \".ptsw-st{display:block;transition:transform .85s var(--ptsw-out);transition-delay:calc(var(--p,0)*45ms)}\",\n  \".ptsw-st>span{display:block;height:1.15em;line-height:1.15}\",\n  \".ptsw-per{display:inline-grid;align-self:flex-end;margin:0 0 .3em .3em;font-size:.34em;font-weight:500;letter-spacing:0;line-height:1;opacity:.5}\",\n  \".ptsw-per>span,.ptsw-bill>span{grid-area:1/1;transition:opacity .3s ease,transform .45s var(--ptsw-out)}\",\n  \".ptsw-per>span[data-off],.ptsw-bill>span[data-off]{opacity:0;transform:translateY(-.4em)}\",\n  \".ptsw-per>span[data-off='1'],.ptsw-bill>span[data-off='1']{transform:translateY(.4em)}\",\n  /* plans */\n  \".ptsw-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:10px;width:100%;margin:0;padding:0;list-style:none}\",\n  \".ptsw-card{position:relative;isolation:isolate;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:2px 16px;padding:14px 16px;border-radius:18px;background:color-mix(in srgb,currentColor 4%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 11%,transparent);transition:transform .45s var(--ptsw-out),box-shadow .3s ease}\",\n  \".ptsw-card>*{position:relative;z-index:2}\",\n  \".ptsw-card::after{content:'';position:absolute;inset:0;border-radius:inherit;background:radial-gradient(260px circle at var(--mx,50%) var(--my,0%),color-mix(in srgb,currentColor 10%,transparent),transparent 70%);opacity:0;transition:opacity .3s ease;pointer-events:none;z-index:0}\",\n  \"@media (hover:hover){.ptsw-card:hover{transform:translateY(-3px)}.ptsw-card:hover::after{opacity:1}}\",\n  \".ptsw-card[data-hl]{background:linear-gradient(180deg,color-mix(in srgb,var(--ptsw-c) 11%,transparent),color-mix(in srgb,currentColor 3%,transparent) 65%);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ptsw-c) 38%,transparent),0 26px 60px -34px var(--ptsw-c)}\",\n  \".ptsw-card[data-hl]::before{content:'';position:absolute;inset:0;z-index:1;padding:1.5px;border-radius:inherit;pointer-events:none;background:conic-gradient(from var(--ptsw-a),transparent 0 60%,var(--ptsw-c) 82%,color-mix(in srgb,var(--ptsw-c) 40%,#fff) 90%,transparent 100%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0);animation:ptsw-spin 5s linear infinite}\",\n  \"@keyframes ptsw-spin{to{--ptsw-a:360deg}}\",\n  \".ptsw-head{min-width:0}\",\n  \".ptsw-name{display:flex;align-items:center;gap:8px;margin:0;font-size:1rem;font-weight:600;line-height:1.3}\",\n  \".ptsw-tag{padding:3px 8px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;white-space:nowrap;color:#fff;background:var(--ptsw-c)}\",\n  \".ptsw-desc{margin:2px 0 0;font-size:.8125rem;line-height:1.4;opacity:.55}\",\n  \".ptsw-card .ptsw-price{--ptsw-fs:var(--ptsw-pfs,1.75rem)}\",\n  \".ptsw-card .ptsw-per{font-size:.42em}\",\n  \".ptsw-bill,.ptsw-feat,.ptsw-cta{display:none}\",\n  \".ptsw-bill{margin:6px 0 0;font-size:.75rem;line-height:1.3;opacity:.55}\",\n  \".ptsw-feat{margin:0;padding:0;list-style:none}\",\n  \".ptsw-feat li{display:flex;align-items:center;gap:10px;font-size:.875rem;line-height:1.3;padding:5px 0}\",\n  \".ptsw-feat svg{flex:none;width:16px;height:16px;color:var(--ptsw-c)}\",\n  \".ptsw-cta{appearance:none;width:100%;min-height:44px;margin:0;padding:12px 16px;border:0;border-radius:12px;cursor:pointer;color:inherit;font:inherit;font-size:.875rem;font-weight:600;background:color-mix(in srgb,currentColor 8%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 14%,transparent);transition:background-color .25s ease,transform .25s var(--ptsw-spring),box-shadow .25s ease;-webkit-tap-highlight-color:transparent}\",\n  \".ptsw-cta:hover{background:color-mix(in srgb,currentColor 14%,transparent)}\",\n  \".ptsw-cta:active{transform:scale(.97)}\",\n  \".ptsw-card[data-hl] .ptsw-cta{color:#fff;background:var(--ptsw-c);box-shadow:inset 0 1px 0 rgba(255,255,255,.3),0 10px 24px -10px var(--ptsw-c)}\",\n  \".ptsw-card[data-hl] .ptsw-cta:hover{filter:brightness(1.08)}\",\n  \"@container (min-width:620px){\",\n  \".ptsw-grid{grid-template-columns:repeat(var(--ptsw-n,3),minmax(0,1fr));gap:14px}\",\n  \".ptsw-card{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:26px 24px 22px}\",\n  \".ptsw-card .ptsw-price{--ptsw-fs:var(--ptsw-pfs,3rem);margin-top:22px}\",\n  \".ptsw-card .ptsw-per{font-size:.34em}\",\n  \".ptsw-bill{display:grid}\",\n  \".ptsw-feat{display:block;margin:20px 0 22px;flex:1}\",\n  \".ptsw-cta{display:block}\",\n  \".ptsw-name{font-size:1.0625rem}\",\n  \"}\",\n  \"@media (prefers-reduced-motion:reduce){\",\n  \".ptsw-th,.ptsw-sw::before,.ptsw-lt,.ptsw-badge,.ptsw-d,.ptsw-st,.ptsw-per>span,.ptsw-bill>span,.ptsw-card,.ptsw-card::after,.ptsw-cta{transition-duration:.01s!important;transition-delay:0s!important}\",\n  \".ptsw-card[data-hl]::before{animation:none!important;--ptsw-a:200deg}\",\n  \".ptsw-card:hover{transform:none!important}\",\n  \"}\",\n].join(\"\\n\");\n\nconst num = (n: number) => (Number.isFinite(n) ? n : 0);\n\nfunction savingLabel(monthly: number, yearly: number, mode: \"percent\" | \"months\"): string | null {\n  const m = num(monthly);\n  const y = num(yearly);\n  if (m <= 0) return null;\n  const pct = Math.round((1 - y / (m * 12)) * 100);\n  if (mode === \"months\") {\n    const free = Math.round(12 - y / m);\n    if (free >= 1) return free + (free === 1 ? \" month free\" : \" months free\");\n  }\n  return pct > 0 ? \"Save \" + pct + \"%\" : null;\n}\n\nfunction Swap({ on, a, b, className }: { on: boolean; a: string; b: string; className: string }) {\n  return (\n    <span className={className}>\n      <span data-off={on ? \"\" : undefined} aria-hidden=\"true\">\n        {a}\n      </span>\n      <span data-off={on ? undefined : \"1\"} aria-hidden=\"true\">\n        {b}\n      </span>\n    </span>\n  );\n}\n\nfunction Price({ monthly, yearly, on, currency }: { monthly: number; yearly: number; on: boolean; currency: string }) {\n  const a = String(num(monthly));\n  const b = String(num(yearly));\n  const s = on ? b : a;\n  const len = Math.max(a.length, b.length);\n  const slots: { p: number; ch: string }[] = [];\n  for (let p = len - 1; p >= 0; p--) slots.push({ p, ch: s.charAt(s.length - 1 - p) });\n  return (\n    <div className=\"ptsw-price\">\n      <span className=\"ptsw-sr\">\n        {currency}\n        {s} per {on ? \"year\" : \"month\"}\n      </span>\n      <span className=\"ptsw-cur\" aria-hidden=\"true\">\n        {currency}\n      </span>\n      <span className=\"ptsw-num\" aria-hidden=\"true\">\n        {slots.map(({ p, ch }) => {\n          const digit = ch >= \"0\" && ch <= \"9\" ? Number(ch) : -1;\n          if (digit >= 0) {\n            return (\n              <span key={p} className=\"ptsw-d\">\n                <span className=\"ptsw-st\" style={{ transform: \"translateY(-\" + digit * 10 + \"%)\", \"--p\": p } as CSSProperties}>\n                  {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => (\n                    <span key={d}>{d}</span>\n                  ))}\n                </span>\n              </span>\n            );\n          }\n          return (\n            <span key={p} className=\"ptsw-d\" data-s=\"\" data-e={ch === \"\" ? \"\" : undefined}>\n              {ch}\n            </span>\n          );\n        })}\n      </span>\n      <Swap className=\"ptsw-per\" on={on} a=\"/mo\" b=\"/yr\" />\n    </div>\n  );\n}\n\nconst CHECK = (\n  <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\">\n    <path d=\"M3.5 8.5l3 3 6-7\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n  </svg>\n);\n\nexport function PricingToggleSwitch({\n  monthlyPrice = 29,\n  yearlyPrice = 290,\n  currency = \"$\",\n  yearly,\n  defaultYearly = false,\n  onYearlyChange,\n  plans,\n  onSelectPlan,\n  saveLabel = \"percent\",\n  monthlyLabel = \"Monthly\",\n  yearlyLabel = \"Yearly\",\n  color = \"#ef4444\",\n  className = \"\",\n}: {\n  monthlyPrice?: number;\n  yearlyPrice?: number;\n  currency?: string;\n  yearly?: boolean;\n  defaultYearly?: boolean;\n  onYearlyChange?: (yearly: boolean) => void;\n  plans?: PricingPlan[];\n  onSelectPlan?: (plan: PricingPlan, yearly: boolean) => void;\n  saveLabel?: \"percent\" | \"months\";\n  monthlyLabel?: string;\n  yearlyLabel?: string;\n  color?: string;\n  className?: string;\n}) {\n  const [inner, setInner] = useState(defaultYearly);\n  const on = yearly ?? inner;\n  const down = useRef<number | null>(null);\n  const dragged = useRef(false);\n\n  const set = (v: boolean) => {\n    if (yearly === undefined) setInner(v);\n    if (v !== on) onYearlyChange?.(v);\n  };\n\n  const accent = SAFE_COLOR.test(color.trim()) ? color.trim() : \"#ef4444\";\n  const hasPlans = !!plans && plans.length > 0;\n  const ref = hasPlans ? (plans.find((p) => p.highlighted) ?? plans[0]) : undefined;\n  const badge = ref ? savingLabel(ref.monthlyPrice, ref.yearlyPrice, saveLabel) : savingLabel(monthlyPrice, yearlyPrice, saveLabel);\n\n  const onMove = (e: ReactPointerEvent<HTMLElement>) => {\n    const el = e.currentTarget;\n    const r = el.getBoundingClientRect();\n    const k = r.width ? el.offsetWidth / r.width : 1;\n    el.style.setProperty(\"--mx\", (e.clientX - r.left) * k + \"px\");\n    el.style.setProperty(\"--my\", (e.clientY - r.top) * k + \"px\");\n  };\n\n  return (\n    <div\n      className={\"ptsw \" + className}\n      data-single={hasPlans ? undefined : \"\"}\n      data-plans={hasPlans ? \"\" : undefined}\n      style={{ \"--ptsw-c\": accent, \"--ptsw-n\": hasPlans ? Math.min(plans.length, 4) : 1 } as CSSProperties}\n    >\n      <style>{CSS}</style>\n      <div className=\"ptsw-row\">\n        <span className=\"ptsw-lab\" data-on={on ? undefined : \"\"} onClick={() => set(false)}>\n          <span className=\"ptsw-lt\">{monthlyLabel}</span>\n        </span>\n        <button\n          type=\"button\"\n          role=\"switch\"\n          aria-checked={on}\n          aria-label=\"Bill yearly\"\n          className=\"ptsw-sw\"\n          onClick={() => {\n            if (dragged.current) {\n              dragged.current = false;\n              return;\n            }\n            set(!on);\n          }}\n          onPointerDown={(e) => {\n            down.current = e.clientX;\n            dragged.current = false;\n            e.currentTarget.setPointerCapture?.(e.pointerId);\n          }}\n          onPointerUp={(e) => {\n            if (down.current !== null && Math.abs(e.clientX - down.current) > 10) {\n              dragged.current = true;\n              set(e.clientX > down.current);\n            }\n            down.current = null;\n          }}\n          onPointerCancel={() => {\n            down.current = null;\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"ArrowRight\") {\n              e.preventDefault();\n              set(true);\n            } else if (e.key === \"ArrowLeft\") {\n              e.preventDefault();\n              set(false);\n            }\n          }}\n        >\n          <span className=\"ptsw-th\" />\n        </button>\n        <span className=\"ptsw-lab\" data-on={on ? \"\" : undefined} onClick={() => set(true)}>\n          <span className=\"ptsw-lt\">{yearlyLabel}</span>\n          {badge && <span className=\"ptsw-badge\">{badge}</span>}\n        </span>\n      </div>\n      <span className=\"ptsw-sr\" role=\"status\" aria-live=\"polite\">\n        {\"Billing \" + (on ? \"yearly\" : \"monthly\")}\n      </span>\n\n      {hasPlans ? (\n        <ul className=\"ptsw-grid\">\n          {plans.map((plan, i) => {\n            const saved = Math.round(num(plan.monthlyPrice) * 12 - num(plan.yearlyPrice));\n            return (\n              <li key={plan.name + i} className=\"ptsw-card\" data-hl={plan.highlighted ? \"\" : undefined} onPointerMove={onMove}>\n                <div className=\"ptsw-head\">\n                  <h3 className=\"ptsw-name\">\n                    {plan.name}\n                    {plan.highlighted && <span className=\"ptsw-tag\">{plan.badge ?? \"Most popular\"}</span>}\n                  </h3>\n                  {plan.description && <p className=\"ptsw-desc\">{plan.description}</p>}\n                </div>\n                <div className=\"ptsw-pw\">\n                  <Price monthly={plan.monthlyPrice} yearly={plan.yearlyPrice} on={on} currency={currency} />\n                  <Swap\n                    className=\"ptsw-bill\"\n                    on={on}\n                    a=\"Billed monthly\"\n                    b={saved > 0 ? \"Billed yearly, save \" + currency + saved : \"Billed yearly\"}\n                  />\n                </div>\n                {plan.features && plan.features.length > 0 && (\n                  <ul className=\"ptsw-feat\">\n                    {plan.features.map((f) => (\n                      <li key={f}>\n                        {CHECK}\n                        {f}\n                      </li>\n                    ))}\n                  </ul>\n                )}\n                <button type=\"button\" className=\"ptsw-cta\" onClick={() => onSelectPlan?.(plan, on)}>\n                  {plan.cta ?? \"Get started\"}\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      ) : (\n        <Price monthly={monthlyPrice} yearly={yearlyPrice} on={on} currency={currency} />\n      )}\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/pricing-toggle-switch.tsx"}],"meta":{"tier":"free","tags":["pricing","toggle","switch","billing","odometer","plans","border-beam"],"href":"https://viberdy.dev/library/pricing-toggle-switch","aiPrompt":"Create a self-contained React \"use client\" component named PricingToggleSwitch. No dependencies besides React and no Tailwind: it injects its own CSS through a plain <style> element placed beside the markup, never inside a button.\n\nProps (all optional): monthlyPrice=29, yearlyPrice=290, currency=\"$\", yearly (controlled), defaultYearly=false, onYearlyChange(yearly), plans (PricingPlan[] = {name, monthlyPrice, yearlyPrice, description?, features?, highlighted?, badge?, cta?}), onSelectPlan(plan, yearly), saveLabel=\"percent\"|\"months\", monthlyLabel=\"Monthly\", yearlyLabel=\"Yearly\", color=\"#ef4444\" (validated against a hex/rgb/hsl regex), className.\n\nDefault render (no plans): a rounded-16 box (1px ring via inset box-shadow at 14% currentColor, padding 32) containing a row [Monthly] [switch] [Yearly + badge] and below it the big price. Everything inherits currentColor so it works on a light or dark page; the only colours are currentColor mixes and the accent (--ptsw-c).\n\nSwitch: native <button role=\"switch\" aria-checked aria-label=\"Bill yearly\">, 56x32 pill, track = 18% currentColor with an inset 1px ring; an accent gradient layer (accent mixed 18% with white, to accent) fades in over .35s when on, with a 0 6px 18px -6px accent glow. Thumb: white, 24px high, left 4px top 4px, shadow 0 2px 6px rgba(0,0,0,.35). Checked: translateX(48px - thumbWidth). While :active the thumb width grows 24 to 31px. Transform transition .55s cubic-bezier(.34,1.56,.64,1), width .3s same curve. The hit area extends 6px / 4px past the track with a ::after. Pointer drag: on pointerdown record x and setPointerCapture; on pointerup, if |dx| > 10, set yearly = dx > 0 and suppress the following click. ArrowRight sets yearly, ArrowLeft monthly. Clicking the Monthly / Yearly labels also sets the period. The inactive label text is at 50% opacity.\n\nBadge: 10px bold pill, text and 14% tinted background in the accent; when yearly is active it fills solid accent with white text and scales 1.05. Text: percent mode \"Save N%\" with N = round((1 - yearly/(monthly*12))*100); months mode \"N months free\" with N = round(12 - yearly/monthly) when N >= 1, otherwise falls back to percent; hidden when there is no saving. With plans, the badge is computed from the highlighted (or first) plan.\n\nPrice odometer: font-size var(--ptsw-fs, 2.25rem), weight 800, letter-spacing -.035em, line-height 1.15. Currency symbol at .58em, 55% opacity, nudged down .12em. Each character of String(price) is a slot, aligned from the right; slot count = max length of the two prices. A digit slot is a .62em wide, 1.15em high window with overflow hidden and a vertical mask (transparent, black 12%, black 88%, transparent) holding a strip of 0-9; the strip is translateY(-digit*10%) with transition .85s cubic-bezier(.2,.8,.2,1), delayed by slotIndexFromRight*45ms so the last digit lands first. Non-digit characters (\".\") are .3em static slots; unused slots have width 0 and opacity 0 and transition width .55s so columns open and close. The \"/mo\" and \"/yr\" suffix (.34em, 50% opacity) crossfades with a .4em vertical offset. Render a screen-reader-only \"$290 per year\" and mark the visual digits aria-hidden. A sr-only role=status aria-live=polite span announces \"Billing yearly\" / \"Billing monthly\".\n\nPlans mode: the root becomes width:100% with container-type:inline-size. A list of cards: below 620px container width each card is a compact row (name + description on the left, rolling price on the right, no features, no button); at 620px and up they are columns (repeat(n,1fr), n = plans.length capped at 4, gap 14) with padding 26/24/22, price 3rem, a \"Billed monthly\" / \"Billed yearly, save $X\" line that crossfades, a checkmark feature list (accent check, 14px text) and a full-width 44px CTA button. Card surface: 4% currentColor fill, 1px inset ring at 11%, radius 18. Hover (hover:hover only) lifts 3px and fades in a radial light, 260px, 10% currentColor, centred on the pointer (--mx/--my set from pointermove in layout pixels: (clientX - rect.left) * (offsetWidth / rect.width)). The highlighted plan: accent-tinted top gradient, 38% accent ring, a \"Most popular\" (or plan.badge) accent pill, an accent CTA with white text, and a border beam: a ::before with padding 1.5px and a conic-gradient(from var(--ptsw-a), transparent 0-60%, accent 82%, accent mixed 40% with white 90%, transparent) masked to the border box (mask content-box exclude), rotating via @property --ptsw-a (angle) keyframes 0 to 360deg over 5s linear infinite. It stays a static ring where @property is unsupported.\n\nA11y and polish: focus-visible outline 2px accent offset 3px, !important, on the switch and CTA; reduced motion shortens every transition to .01s, stops the beam on a static angle and disables the hover lift; no hover-only features; no effects are needed, all motion is CSS transitions driven by data attributes.","license":"https://viberdy.dev/license"}}