All patterns

Radio Select Dot

The chosen dot springs out of its ring while the previous one lets go a little faster.

formsminimalsubtleinteraction · finite · starter · ~0.2s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

242 lines · react + motion only
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Radio Select Dot
 *
 * The chosen option's dot springs out of the center of its ring while
 * the previous one lets go a little faster, so the handover has a
 * direction. A real radio group: arrow keys move and select, the group
 * is one stop in the page's tab order.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, so the group reads correctly on a
 * light page and on a dark one.
 * Works with zero props; tune via `variant`, `legend`, `options`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RadioOption = {
  label: string;
  /** Second line under the label. */
  detail?: string;
  /** Right-aligned value — a price, a size, a count. */
  trailing?: string;
};

export type RadioSelectDotProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The group's accessible name, rendered above the options. */
  legend?: string;
  /** Two or more choices. */
  options?: RadioOption[];
  /** Index selected on first render. */
  defaultIndex?: number;
  /** Dot and ring color of the chosen option. */
  accent?: string;
  /** Fires with the newly selected index. */
  onSelect?: (index: number) => void;
};

type VariantConfig = {
  /** Grows the dot inside the chosen ring. */
  dotIn: { type: "spring"; stiffness: number; damping: number };
  /** Seconds for the leaving dot to let go. Shorter than the arrival. */
  dotOut: number;
};

// Quality rule: the dot spring stays at or above a 0.8 damping ratio. A
// dot that overshoots its ring reads as a bubble rather than a marker,
// and this control is chosen from, not played with. Variants differ in
// tempo, never in bounce count.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Practically instant. For a long list of choices.
  subtle: {
    dotIn: { type: "spring", stiffness: 900, damping: 56 },
    dotOut: 0.05,
  },
  // One soft settle on arrival. All-purpose.
  default: {
    dotIn: { type: "spring", stiffness: 560, damping: 40 },
    dotOut: 0.13,
  },
  // A rounder arrival, for three or four options that carry weight.
  playful: {
    dotIn: { type: "spring", stiffness: 240, damping: 25 },
    dotOut: 0.21,
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or ring that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_OPTIONS: RadioOption[] = [
  { label: "Standard", detail: "Arrives in 4-6 business days", trailing: "Free" },
  { label: "Express", detail: "Arrives in 2 business days", trailing: "$9" },
  { label: "Same day", detail: "Order before 2 pm", trailing: "$19" },
];

const RING = 19;

export default function RadioSelectDot({
  variant = "default",
  legend = "Shipping speed",
  options = DEFAULT_OPTIONS,
  defaultIndex = 0,
  accent = "#5B5BD6",
  onSelect,
}: RadioSelectDotProps) {
  const [selected, setSelected] = useState(defaultIndex);
  const [ring, setRing] = useState(-1);
  const legendId = useId();
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const select = (index: number) => {
    setSelected(index);
    onSelect?.(index);
  };

  return (
    <div style={{ width: 296, color: "inherit" }}>
      <div
        id={legendId}
        style={{ fontSize: 12, fontWeight: 650, letterSpacing: 0.3, opacity: 0.55 }}
      >
        {legend.toUpperCase()}
      </div>

      <div
        role="radiogroup"
        aria-labelledby={legendId}
        style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10 }}
      >
        {options.map((option, index) => {
          const isSelected = index === selected;
          return (
            <button
              key={option.label}
              type="button"
              role="radio"
              aria-checked={isSelected}
              // Roving tabindex: the whole group is one tab stop and the
              // arrow keys move inside it, which is what a radio group
              // does everywhere else on the platform.
              tabIndex={isSelected ? 0 : -1}
              onClick={() => select(index)}
              onKeyDown={(event) => {
                const last = options.length - 1;
                let next = -1;
                if (event.key === "ArrowDown" || event.key === "ArrowRight")
                  next = index === last ? 0 : index + 1;
                else if (event.key === "ArrowUp" || event.key === "ArrowLeft")
                  next = index === 0 ? last : index - 1;
                else if (event.key === "Home") next = 0;
                else if (event.key === "End") next = last;
                if (next === -1) return;
                event.preventDefault();
                select(next);
                const sibling = event.currentTarget.parentElement?.children[next];
                if (sibling instanceof HTMLElement) sibling.focus();
              }}
              // The ring is for keyboard users only. `:focus-visible` is
              // the browser's own answer to "was this focus deliberate?"
              // — read it instead of guessing at the input modality.
              onFocus={(event) =>
                setRing(event.currentTarget.matches(":focus-visible") ? index : -1)
              }
              onBlur={() => setRing(-1)}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 12,
                width: "100%",
                padding: "11px 13px",
                borderRadius: 12,
                textAlign: "left",
                fontFamily: "inherit",
                color: "inherit",
                cursor: "pointer",
                outline: "none",
                // Selection also tints the row, so the choice is legible
                // from across the form and not only at the dot. Colors
                // settle on a CSS transition, keeping the animation loop
                // to transform and opacity.
                background: isSelected ? tone(8) : "transparent",
                border: `1px solid ${isSelected ? accent : tone(14)}`,
                boxShadow: ring === index ? `0 0 0 3px ${accent}66` : "none",
                transition:
                  "background-color 180ms ease-out, border-color 180ms ease-out, box-shadow 140ms ease-out",
                WebkitTapHighlightColor: "transparent",
              }}
            >
              <span
                aria-hidden
                style={{
                  position: "relative",
                  flexShrink: 0,
                  width: RING,
                  height: RING,
                  borderRadius: RING,
                  border: `1.5px solid ${isSelected ? accent : tone(30)}`,
                  transition: "border-color 180ms ease-out",
                }}
              >
                {/* A solid dot, so scaling it costs one compositor
                    property and deforms nothing. Arrival is a spring;
                    release is a shorter tween, which is what gives the
                    handover its direction. */}
                <motion.span
                  initial={false}
                  animate={{ scale: isSelected ? 1 : 0 }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : isSelected
                        ? cfg.dotIn
                        : { duration: cfg.dotOut, ease: "easeIn" }
                  }
                  style={{
                    position: "absolute",
                    inset: 3.5,
                    borderRadius: RING,
                    background: accent,
                  }}
                />
              </span>

              <span style={{ flex: 1, minWidth: 0 }}>
                <span style={{ display: "block", fontSize: 13.5, fontWeight: 600 }}>
                  {option.label}
                </span>
                {option.detail ? (
                  <span
                    style={{
                      display: "block",
                      fontSize: 12,
                      opacity: 0.55,
                      marginTop: 2,
                    }}
                  >
                    {option.detail}
                  </span>
                ) : null}
              </span>

              {option.trailing ? (
                <span style={{ fontSize: 13, fontWeight: 600, opacity: 0.8 }}>
                  {option.trailing}
                </span>
              ) : null}
            </button>
          );
        })}
      </div>
    </div>
  );
}

About this pattern

A single-choice group where the handover has a direction. The arriving dot is a spring and the leaving one is a shorter eased tween, so the eye is pulled toward the new choice instead of watching two markers trade places at the same rate. The chosen row also tints, because a 4mm dot is not enough to carry the state across a whole form. Arrow keys move and select the way a radio group does everywhere else, and the group is one stop in the page's tab order rather than three.

Shipping optionsPlan chooserPayment methodSurvey question

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    Whole-row selection with the marker as confirmation, not as the target.

Related patterns