Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Plan orbs

A pricing plan picker where each plan carries a glass orb: the chosen one fills with swirling colour as the thumb slides under it.

plan-orbs/plan-orbs.tsx
Live · interactive

Choose your plan

Start small, and switch whenever you like.

Save 20%

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Choosing one of two to four pricing plans at checkout or on an upgrade screen, where the choice deserves a moment of delight.
  • Plans that differ by a single price and a short caption, so the whole decision fits in one card.
  • Offering monthly or yearly billing on the same screen: the switch shows the saving and the price rolls to it.
  • Onboarding and upgrade flows seen once or twice, where a living, colourful pick makes the moment memorable.

When not to use

  • Comparing plans feature by feature. Use a pricing table with a row per feature, and put the choice under it.
  • More than four plans: the pills get too narrow for a name and a caption. Use a list of radio cards.
  • Settings or filters picked often in a dense form. A plain segmented control is quicker to read.
  • Prices that need more than whole dollars, taxes or seats. Show the full total next to the button instead.
  • Pages that must stay quiet and still: the orbs turn and glow. Use plain radio cards there.

You can add this component with the shadcn CLI or by hand:

Into components/plan-orbs of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/plan-orbs.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

upgrade.tsx
'use client';import { PlanOrbs } from './plan-orbs';export function Upgrade() {  return (    <PlanOrbs      defaultPlan="pro"      defaultYearly      autoplay={false}      onContinue={({ plan, yearly }) => console.log(plan.id, yearly ? 'yearly' : 'monthly')}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Three plans sit in a recessed track, each with a small glass marble beside its name. Choose one and a raised thumb slides under it on a spring, and its orb fills with slowly swirling colour as the thumb arrives, while the others stay frosted; every highlight leans towards the pointer. A switch bills monthly or yearly, and the price rolls to its new value digit by digit.

  • Each orb is a stack of CSS gradients, with no image and no blur filter: a contact shadow, a frosted ball, the coloured liquid, two pools of light, a caustic, a rim and a specular highlight. Unchosen, the liquid sinks to a faint tint at the bottom of the ball; chosen, it rises into it, the rim deepens into the colour and a glow drawn as a radial gradient lights up around it.
  • The colour swirls because the two pools turn in opposite directions at different speeds, with CSS transform animations. They run only in the chosen orb and the one under the pointer, and only while the stage is on screen, so at rest it is compositor work alone.
  • The light follows the pointer as if a lamp hung just above the screen at its tip. For each orb the component works out where the ball faces it and moves its highlight and caustic with transforms; the orbs are measured once as the pointer comes in, and a light is only written when it has moved a step.
  • The plans are native radio buttons labelled by their pills. The raised thumb is one fixed-size layer slid by a translate on a spring sampled into a CSS linear() easing, and the new orb fills after a delay taken from the same spring, as the thumb arrives.
  • The price is an odometer: each digit is a reel of 0 to 9 in a window one line tall, slid to its digit on a softer spring a beat after the digit to its left, so $20 rolls to $45 through every digit in between.
  • The stage and the orbs’ glass are light-dark() tokens on the root, so it follows the page’s color-scheme, or the colorScheme prop; the orbs’ liquid, glow and highlight keep the plan’s colours, while the frosted ball becomes smoked glass at night.
PropTypeDefaultDescription
plansreadonly Plan[]PLANSThe plans, in order (three fit the track best): an id, a name, a caption, dollars a month billed monthly and billed yearly, the orb’s three colours and an optional tag for the pill’s top edge.
defaultPlanstring'pro'The id of the plan chosen at first.
defaultYearlybooleanfalseWhether it starts billed yearly.
onChange(choice: Choice) => void—Called with the plan and the period whenever either changes.
onContinue(choice: Choice) => void—Called with the plan and the period when the reader presses Continue.
autoplaybooleantrueWhile on screen and untouched, a demo pointer moves between the plans and flips the period.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

plan-orbs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties } from 'react';import { GhostPointer, runDemo } from './ghost-pointer';import { Odometer } from './odometer';import { Orb } from './orb';import { PLANS, priceOf, type Plan } from './plans';import { spring } from './spring';import { useAutoplay } from './use-autoplay';import styles from './plan-orbs.module.css';/* * Plan orbs: a pricing plan picker where each plan carries a small glass orb. * * - The plans are native radio buttons in a recessed track, so the arrow keys move and select. A *   raised thumb slides under the chosen plan on a spring, and the orb in it fills with colour as *   the thumb arrives; the other orbs are frosted, nearly colourless (orb.tsx). * - The orbs are layers of CSS gradients, and their light follows the pointer: the component *   reads where it is and sets two custom properties on each orb's highlight and caustic, which *   move on transforms. The colour turns slowly inside them, with transforms too, only while the *   stage is on screen, and only in the chosen orb and the one under the pointer. * - A switch bills monthly or yearly (20% less), and the price rolls to its new value digit by
plan-orbs.module.css
/* Plan orbs (plan-orbs.tsx; the orbs and the price have stylesheets of their own). Sizes are in   em: the stage sets the font-size from its own width (a container query), up to 16px. --index   and --count place the thumb; --slide and --roll, springs as linear() easings, and their   lengths come from the component. The stage passes --arrive (when the thumb gets there) and   --orb-play (whether the orbs' colour may turn) down to the orbs.   Colours are the tokens below, light, then light-dark(light, dark) pairs: the component takes the   color-scheme it inherits from the page (light unless the page says otherwise), or the one its   colorScheme prop sets (data-color-scheme). The pairs sit in @supports, which a build that   rewrites light-dark() for older browsers leaves as written; without light-dark(), the light   colours stay. The orbs have tokens of their own (orb.module.css). */.root {  /* The dotted stage and its edge; the card on it, the lift under the card, and the line across     the card's foot. */  --stage: #e6e6e9;  --dots: rgb(0 0 0 / 0.05);  --stage-edge: rgb(0 0 0 / 0.06);  --surface: #fff;  --edge: rgb(0 0 0 / 0.04);  --shadow-near: rgb(0 0 0 / 0.05);  --shadow-far: rgb(30 30 40 / 0.28);  --shadow:    0 0 0 1px var(--edge), 0 1px 2px var(--shadow-near), 0 1.4em 2.8em -1.4em var(--shadow-far);
orb.tsx
import type { CSSProperties, Ref } from 'react';import styles from './orb.module.css';/* * A glass marble, in layers of CSS gradients (orb.module.css): a contact shadow, a frosted ball, * the coloured liquid inside it with two pools of light turning in opposite directions, a soft * caustic where light gathers on the far side, a rim, and a specular highlight. * * - `lit` fills the ball with its colour (the liquid rises into it) and lights a glow round it; *   `stir` gives a hint of colour and sets the liquid turning, for a pill under the pointer. * - Where the light comes from is two custom properties, --lx and --ly, from -1 to 1 (up and to *   the left at rest). The component sets them from the pointer on the two pieces marked *   data-light, so that nothing else restyles: the highlight leans towards the pointer and the *   caustic away from it, on transforms. * - The pools turn with transforms, only while the stage sets --orb-play to running (when it is *   on screen), and only in a ball that is lit or stirred. */export function Orb({  colors,  lit,  stir,  ref,}: {  /** Deep, middle and light: the liquid's colours. */
orb.module.css
/* The orb (orb.tsx): a marble --size wide whose liquid has the colours --c1 (deep) to --c3   (light). The light comes from (--lx, --ly), set by the component as the pointer moves.   --arrive delays the fill until the plan's thumb is there, and --orb-play (running or paused)   lets the pools turn while the stage is on screen. Transforms and opacity do the moving; only   the rim's colour changes otherwise, once, as a plan is chosen.   The liquid's colours are the plan's, in both schemes. The glass round them follows the   color-scheme with light-dark(light, dark) pairs, in @supports as on the stage: pale frosted   glass by day, smoked glass at night, with a softer rim and a deeper contact shadow. */.orb {  /* The ball, from where the light falls to its far edge, and the hairline round it. */  --glass-1: #fff;  --glass-2: #f5f5f8;  --glass-3: #e1e1e8;  --glass-4: #cdcdd7;  --glass-edge: rgb(20 20 40 / 0.12);  /* The rim: its ring of light, its shaded foot and its lit top. */  --rim: rgb(255 255 255 / 0.5);  --rim-shade: rgb(15 15 35 / 0.24);  --rim-light: rgb(255 255 255 / 0.85);  /* Where the ball meets the surface. */  --contact: rgb(25 25 40 / 0.3);  --size: 2.5em;
odometer.tsx
import type { CSSProperties } from 'react';import styles from './odometer.module.css';/* * A whole number that rolls to its new value like an odometer. Each digit is a reel of 0 to 9 in * a window one line tall; a CSS transition slides the reel to its digit, on a spring, a beat * after the digit to its left. Digits are keyed by their place (units, tens...), so a units * reel stays a units reel when the number gets longer. It is decorative: the caller gives screen * readers the number as text. */const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];export function Odometer({ value }: { value: number }) {  const digits = String(Math.max(0, Math.round(value))).split('');  return (    <span className={styles.odometer} aria-hidden="true">      {digits.map((digit, i) => (        <span          key={digits.length - i}          className={styles.window}          style={{ '--digit': digit, '--order': i } as CSSProperties}        >          <span className={styles.sizer}>0</span>
odometer.module.css
/* The odometer (odometer.tsx). It takes its size from its parent's font; --roll and --roll-ms   (the spring the reels slide on) come from the component. Each window is one line tall, with   soft top and bottom edges, and shows the reel's line --digit. */.odometer {  --line: 1.2em;  display: inline-flex;  font-variant-numeric: tabular-nums;  line-height: var(--line);}.window {  position: relative;  display: inline-block;  height: var(--line);  overflow: hidden;  overflow: clip;  -webkit-mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);  mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);}/* A digit's room, so the window is as wide as a digit. */.sizer {  visibility: hidden;}.reel {
plans.ts
/* * The plans to choose from: made up, with their prices in whole dollars a month and the three * colours of their orb (deep, middle, light). Billed yearly, a month costs 20% less. */export type Plan = {  id: string;  name: string;  /** One or two words under the name. */  caption: string;  /** Dollars a month, billed monthly... */  monthly: number;  /** ...and billed yearly. */  yearly: number;  /** The orb's colours, from deep to light. */  colors: readonly [string, string, string];  /** A word on the pill's top edge. */  tag?: string;};export const PLANS: Plan[] = [  {    id: 'basic',    name: 'Basic',
spring.ts
/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = {  /** The curve, as a CSS `linear()` easing. */  easing: string;  /** How long it takes to settle, in ms: the duration to play the curve over. */  duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring {  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.  const damping = 1 - bounce;
ghost-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. */export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class Stopped extends Error {}
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The plans are a radiogroup of native radio buttons, each named by its plan, caption and tag (“Team, For groups, Best value”). Tab reaches the chosen plan, and the arrow keys move and select.
  • The period is a switch, “Bill yearly”, described by “Save 20%”. The price reaches screen readers as text (“$16 a month”), not as rolling digits.
  • Changes the reader makes are announced politely (“Pro, $16 a month, billed yearly.”), and so is Continue. The demo’s changes are not, and it stops at the reader’s first press, key or pointer move over the stage.
  • Under reduced motion the colour does not swirl, the digits change at once, the thumb jumps to its plan, and the demo does not play.