Segmented Toggle with Drifting Colour Halo

A single choice toggle group shaped as a pill. The track is frosted glass, a raised thumb slides under the chosen option with a spring, and two blurred bands of colour drift back and forth along the bottom edge so the rim seems to glow. The demo shows it twice: on a white card as a billing period switch that changes the price below, and on a dark card as an icon view switch. On narrow phones the dark version drops its text and keeps the icons, and each option stays at least 40 pixels tall.

Code

// Adapted from Cult UI (https://www.cult-ui.com/docs/components/halo-toggle-group)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CalendarDays, Columns3, List } from "lucide-react";

const billing = [
  { value: "monthly", label: "Monthly", price: "$18", per: "per seat, each month", note: "Cancel any time." },
  { value: "yearly", label: "Yearly", price: "$15", per: "per seat, billed yearly", note: "Two months free." },
  { value: "team", label: "Team of 10+", price: "$12", per: "per seat, billed yearly", note: "Includes onboarding call." },
];

const views = [
  { value: "list", label: "List", icon: List, text: "14 bookings today, sorted by start time." },
  { value: "board", label: "Board", icon: Columns3, text: "Bookings grouped by room: Studio A, Studio B, Loft." },
  { value: "calendar", label: "Calendar", icon: CalendarDays, text: "Week view with three open slots on Thursday." },
];

const spring = { type: "spring" as const, bounce: 0.12, damping: 34, stiffness: 420 };

type Item = { value: string; label: ReactNode; name: string };

function HaloToggle({ items, value, onChange, label, dark = false }: { items: Item[]; value: string; onChange: (v: string) => void; label: string; dark?: boolean }) {
  const reduce = useReducedMotion();
  const track = useRef<HTMLDivElement>(null);
  const refs = useRef(new Map<string, HTMLButtonElement>());
  const [thumb, setThumb] = useState({ x: 0, width: 0 });

  const measure = useCallback(() => {
    const t = track.current;
    const b = refs.current.get(value);
    if (!t || !b) return;
    const tr = t.getBoundingClientRect();
    const br = b.getBoundingClientRect();
    setThumb({ x: br.left - tr.left, width: br.width });
  }, [value]);

  useEffect(() => {
    measure();
    const t = track.current;
    if (!t) return;
    const ro = new ResizeObserver(measure);
    ro.observe(t);
    return () => ro.disconnect();
  }, [measure]);

  const onKey = (e: KeyboardEvent, index: number) => {
    const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = items[(index + dir + items.length) % items.length];
    onChange(next.value);
    refs.current.get(next.value)?.focus();
  };

  return (
    <div className="relative inline-flex max-w-full rounded-full p-px">
      {/* The halo: two blurred colour bands sliding along the bottom rim, clipped to the pill */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-full">
        <div className="absolute inset-x-0 bottom-0 h-1/2">
          <motion.div
            className="absolute -bottom-4 h-14 w-44 blur-xl"
            style={{ background: "linear-gradient(90deg, #ff0080, #7928ca, #00d4ff, #0070f3)" }}
            animate={reduce ? { left: "30%" } : { left: ["-5%", "75%", "-5%"] }}
            transition={{ duration: 6, ease: "easeInOut", repeat: reduce ? 0 : Infinity }}
          />
          <motion.div
            className="absolute -bottom-3 h-11 w-36 blur-lg"
            style={{ background: "linear-gradient(90deg, #ff4d4d, #f9cb28, #ff0080)" }}
            animate={reduce ? { left: "45%" } : { left: ["65%", "10%", "65%"] }}
            transition={{ duration: 5, ease: "easeInOut", repeat: reduce ? 0 : Infinity }}
          />
        </div>
      </div>

      <div
        className={`relative rounded-full border backdrop-blur-xl ${
          dark
            ? "border-white/10 bg-gradient-to-b from-zinc-900/85 to-zinc-900/60 shadow-[0_1px_0_rgba(255,255,255,0.06)_inset,0_4px_20px_rgba(0,0,0,0.35)]"
            : "border-zinc-200/90 bg-gradient-to-b from-white/85 to-white/60 shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_1px_2px_rgba(0,0,0,0.04),0_4px_14px_rgba(0,0,0,0.05)]"
        }`}
      >
        <div ref={track} role="radiogroup" aria-label={label} className="relative flex p-1">
          <motion.div
            aria-hidden="true"
            initial={false}
            animate={{ x: thumb.x, width: thumb.width, opacity: thumb.width ? 1 : 0 }}
            transition={reduce ? { duration: 0 } : spring}
            className={`pointer-events-none absolute bottom-1 left-0 top-1 rounded-full border ${
              dark
                ? "border-white/10 bg-gradient-to-b from-zinc-700/90 to-zinc-800/60 shadow-[0_1px_0_rgba(255,255,255,0.08)_inset,0_4px_12px_rgba(0,0,0,0.35)]"
                : "border-zinc-200 bg-gradient-to-b from-white to-zinc-100/70 shadow-[0_1px_0_rgba(255,255,255,0.8)_inset,0_2px_8px_rgba(0,0,0,0.08)]"
            }`}
          />
          {items.map((item, i) => {
            const on = item.value === value;
            return (
              <button
                key={item.value}
                ref={(el) => {
                  if (el) refs.current.set(item.value, el);
                  else refs.current.delete(item.value);
                }}
                type="button"
                role="radio"
                aria-checked={on}
                aria-label={item.name}
                tabIndex={on ? 0 : -1}
                onClick={() => onChange(item.value)}
                onKeyDown={(e) => onKey(e, i)}
                className={`relative z-10 flex min-h-[40px] min-w-0 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 text-sm font-medium tracking-tight outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-offset-2 ${
                  dark
                    ? `focus-visible:ring-white/60 focus-visible:ring-offset-zinc-950 ${on ? "text-white" : "text-zinc-400 hover:text-zinc-200"}`
                    : `focus-visible:ring-zinc-900/40 focus-visible:ring-offset-white ${on ? "text-zinc-950" : "text-zinc-600 hover:text-zinc-900"}`
                }`}
              >
                {item.label}
              </button>
            );
          })}
        </div>
      </div>
    </div>
  );
}

export default function SegmentedToggleWithDriftingColourHalo() {
  const reduce = useReducedMotion();
  const [plan, setPlan] = useState("yearly");
  const [view, setView] = useState("board");
  const chosen = billing.find((b) => b.value === plan)!;
  const shown = views.find((v) => v.value === view)!;
  const swap = reduce
    ? { initial: false as const, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : { initial: { opacity: 0, y: 8, filter: "blur(4px)" }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, y: -8, filter: "blur(4px)" } };

  return (
    <section className="bg-zinc-100 px-4 py-12 sm:px-6 sm:py-16">
      <div className="mx-auto grid max-w-4xl gap-5 md:grid-cols-2">
        <div className="flex flex-col items-center rounded-3xl bg-white px-5 py-10 text-center shadow-sm ring-1 ring-zinc-200">
          <h2 className="text-lg font-semibold tracking-tight text-zinc-950">Roomkeeper Pro</h2>
          <p className="mt-1 text-sm text-zinc-600">Choose how you would like to pay.</p>
          <div className="mt-6">
            <HaloToggle label="Billing period" value={plan} onChange={setPlan} items={billing.map((b) => ({ value: b.value, label: b.label, name: b.label }))} />
          </div>
          <div className="mt-8 h-[92px]">
            <AnimatePresence mode="wait" initial={false}>
              <motion.div key={chosen.value} {...swap} transition={{ duration: 0.2 }}>
                <p className="text-5xl font-semibold tracking-tight tabular-nums text-zinc-950">{chosen.price}</p>
                <p className="mt-1 text-sm text-zinc-600">{chosen.per}</p>
                <p className="mt-1 text-sm font-medium text-zinc-900">{chosen.note}</p>
              </motion.div>
            </AnimatePresence>
          </div>
        </div>

        <div className="flex flex-col items-center rounded-3xl bg-zinc-950 px-5 py-10 text-center ring-1 ring-white/10">
          <h2 className="text-lg font-semibold tracking-tight text-white">Today at the studio</h2>
          <p className="mt-1 text-sm text-zinc-400">Switch the way bookings are laid out.</p>
          <div className="mt-6">
            <HaloToggle
              dark
              label="Booking view"
              value={view}
              onChange={setView}
              items={views.map((v) => ({
                value: v.value,
                name: v.label,
                label: (
                  <>
                    <v.icon className="h-4 w-4" aria-hidden="true" />
                    <span className="hidden sm:inline">{v.label}</span>
                  </>
                ),
              }))}
            />
          </div>
          <div className="mt-8 flex h-[92px] items-center">
            <AnimatePresence mode="wait" initial={false}>
              <motion.p key={shown.value} {...swap} transition={{ duration: 0.2 }} className="max-w-[16rem] text-base text-zinc-200">
                {shown.text}
              </motion.p>
            </AnimatePresence>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/segmented-toggle-with-drifting-colour-halo.json

When to use it

How to customize it

Accessibility

Questions

How does the thumb know where to go?

After each change the component measures the chosen button against the track with `getBoundingClientRect` and animates the thumb's `x` and `width` to match. A `ResizeObserver` measures again when the track changes size, so it stays aligned on rotation or resize.

Why measure instead of using a shared layout animation?

Measuring lets one element slide and resize smoothly between options of different widths, without re-mounting anything, and it keeps the thumb underneath the labels in a single layer.

What creates the glow on the rim?

The outer wrapper has one pixel of padding. Behind the frosted track, two heavily blurred gradient blocks move left and right along the bottom. Only the pixel wide edge and a faint tint through the glass are visible.

Can options have different widths?

Yes. Each option is as wide as its label here, and the thumb follows whatever width the chosen button has. Add `flex-1` to the buttons if you would rather have equal segments.

How do I read the selected value?

It is a controlled component: pass `value` and `onChange`, and keep the value in your own state as the demo does with `plan` and `view`.

Adapted from Halo Toggle Group in Cult UI, used under its open-source licence. Licences and credits

Related: Segmented Control with Spring Pill and Morphing Views, Sun to Moon Theme Toggle with Circular Reveal, Pricing Table with Monthly and Yearly Toggle.

More toggles and switches or all free UI components. Start for free to have a full site built around it.

More in Toggles

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap