Segmented Control with Spring Pill and Morphing Views

A four-way view switcher for List, Grid, Board and Timeline. A dark pill springs from one segment to the next, and each icon plays a small animation as it becomes active: lines extend, squares spin in, columns drop, bars slide. Below it, the same six project cards glide into their new places, from rows to tiles to status columns to timeline bars. On a phone the control keeps the label only on the selected segment so it fits a narrow screen.

Code

import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";

type View = "list" | "grid" | "board" | "timeline";

const views: { key: View; label: string; caption: string[] }[] = [
  { key: "list", label: "List", caption: ["6 projects, sorted by start date"] },
  { key: "grid", label: "Grid", caption: ["6 projects, pinned first"] },
  { key: "board", label: "Board", caption: ["Planned", "Active", "Done"] },
  { key: "timeline", label: "Timeline", caption: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] },
];

/** status: 0 planned, 1 in progress, 2 done. start and span are in twelfths of the timeline width. */
const projects = [
  { name: "Brand refresh", client: "Linden Dental", initials: "LD", status: 2, done: 100, start: 0, span: 4, tone: "bg-emerald-700" },
  { name: "Booking flow", client: "Kestrel Yoga", initials: "KY", status: 1, done: 70, start: 1, span: 6, tone: "bg-sky-700" },
  { name: "Menu photos", client: "Olive and Ash", initials: "OA", status: 1, done: 45, start: 3, span: 4, tone: "bg-rose-700" },
  { name: "Spring campaign", client: "Fernhill Nursery", initials: "FN", status: 1, done: 20, start: 4, span: 5, tone: "bg-violet-700" },
  { name: "Online store", client: "Tallow Candle Co", initials: "TC", status: 0, done: 0, start: 6, span: 6, tone: "bg-amber-700" },
  { name: "Launch video", client: "Northbank Cycles", initials: "NC", status: 2, done: 100, start: 8, span: 4, tone: "bg-teal-700" },
];

const statusNames = ["Planned", "Active", "Done"];

const frames: Record<View, string> = {
  list: "grid-cols-1 grid-rows-6 gap-2",
  grid: "grid-cols-2 grid-rows-3 gap-2 sm:grid-cols-3 sm:grid-rows-2 sm:gap-3",
  board: "grid-cols-3 grid-rows-3 gap-2 sm:gap-3",
  timeline: "grid-cols-1 grid-rows-6 gap-2",
};

function ViewIcon({ view, active, still }: { view: View; active: boolean; still: boolean }) {
  const box = { transformBox: "fill-box" as const };
  const t = (i: number) => (still ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 16, delay: active ? 0.05 + i * 0.06 : 0 });
  return (
    <svg viewBox="0 0 20 20" className="h-5 w-5 flex-none" fill="currentColor" aria-hidden="true">
      {view === "list" &&
        [4, 9, 14].map((y, i) => (
          <motion.rect key={y} x="3" y={y} width="14" height="2.4" rx="1.2" style={{ ...box, transformOrigin: "0% 50%" }} initial={false} animate={{ scaleX: active ? [0.3, 1] : [1, 0.6, 1][i] }} transition={t(i)} />
        ))}
      {view === "grid" &&
        [[3, 3], [11, 3], [3, 11], [11, 11]].map(([x, y], i) => (
          <motion.rect key={i} x={x} y={y} width="6" height="6" rx="1.6" style={{ ...box, transformOrigin: "50% 50%" }} initial={false} animate={{ scale: active ? [0.2, 1] : 0.82, rotate: active ? [-90, 0] : 0 }} transition={t(i)} />
        ))}
      {view === "board" &&
        [[3, 13], [8.2, 8], [13.4, 11]].map(([x, h], i) => (
          <motion.rect key={i} x={x} y="3.5" width="3.6" height={h} rx="1.4" style={{ ...box, transformOrigin: "50% 0%" }} initial={false} animate={{ scaleY: active ? [0.2, 1] : 0.7 }} transition={t(i)} />
        ))}
      {view === "timeline" &&
        [[3, 4, 8], [7, 9, 10], [5, 14, 7]].map(([x, y, w], i) => (
          <motion.rect key={i} x={x} y={y} width={w} height="2.6" rx="1.3" initial={false} animate={{ x: active ? [-4, 0] : 0, opacity: active ? [0.2, 1] : 0.75 }} transition={t(i)} />
        ))}
    </svg>
  );
}

export default function SegmentedControlWithSpringPillAndMorphingViews() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { amount: 0.3 });
  const [view, setView] = useState<View>("list");
  const [auto, setAuto] = useState(true);
  const first = useRef(true);

  // Demo: steps through the views until someone picks one.
  useEffect(() => {
    if (!auto || reduce || !inView) return;
    const id = window.setTimeout(() => {
      first.current = false;
      setView((v) => views[(views.findIndex((x) => x.key === v) + 1) % views.length].key);
    }, first.current ? 950 : 1550);
    return () => window.clearTimeout(id);
  }, [auto, view, inView, reduce]);

  const pick = (v: View) => {
    setAuto(false);
    setView(v);
  };
  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = views[(views.findIndex((x) => x.key === view) + dir + views.length) % views.length].key;
    pick(next);
    e.currentTarget.querySelector<HTMLButtonElement>(`[data-view="${next}"]`)?.focus();
  };

  const move = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 360, damping: 32, mass: 0.9 };
  const caption = views.find((v) => v.key === view)!.caption;

  return (
    <section className="bg-white px-4 py-14 text-zinc-900 sm:px-6 sm:py-20">
      <div ref={ref} className="mx-auto max-w-3xl">
        <div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <p className="text-sm font-medium text-emerald-700">Studio workspace</p>
            <h2 className="mt-1 text-3xl font-semibold tracking-tight sm:text-4xl">Projects</h2>
          </div>
          <div role="radiogroup" aria-label="Project view" onKeyDown={onKey} className="inline-flex self-start rounded-full bg-zinc-100 p-1 ring-1 ring-inset ring-zinc-200 sm:self-auto">
            {views.map((v) => {
              const active = v.key === view;
              return (
                <button
                  key={v.key}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  aria-label={v.label}
                  tabIndex={active ? 0 : -1}
                  data-view={v.key}
                  onClick={() => pick(v.key)}
                  className={`relative flex min-h-[44px] items-center gap-2 rounded-full px-3.5 text-sm font-semibold transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 sm:px-4 ${active ? "text-white" : "text-zinc-600 hover:text-zinc-900"}`}
                >
                  {active && <motion.span layoutId="segmented-control-pill" aria-hidden="true" className="absolute inset-0 rounded-full bg-zinc-900 shadow-md shadow-zinc-900/25" transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 34 }} />}
                  <span className="relative flex items-center gap-2">
                    <ViewIcon view={v.key} active={active} still={!!reduce} />
                    <span className={active ? "inline" : "hidden sm:inline"}>{v.label}</span>
                  </span>
                </button>
              );
            })}
          </div>
        </div>

        <div className="mt-8 rounded-3xl border border-zinc-200 bg-zinc-50 p-3 sm:p-4">
          <div className="relative h-8 overflow-hidden">
            <AnimatePresence initial={false} mode="popLayout">
              <motion.div
                key={view}
                className="absolute inset-0 grid items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-zinc-600 sm:gap-3"
                style={{ gridTemplateColumns: `repeat(${caption.length}, minmax(0, 1fr))` }}
                initial={reduce ? false : { y: 14, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={reduce ? { opacity: 0 } : { y: -14, opacity: 0 }}
                transition={{ duration: 0.25 }}
              >
                {caption.map((c) => (
                  <span key={c} className="truncate">{c}</span>
                ))}
              </motion.div>
            </AnimatePresence>
          </div>
          <ul className={`mt-1 grid h-[344px] ${frames[view]}`} style={{ gridAutoFlow: view === "board" ? "row dense" : "row" }}>
            {projects.map((p) => {
              const bar = view === "timeline";
              const tall = view === "grid" || view === "board";
              return (
                <motion.li
                  key={p.name}
                  layout
                  transition={move}
                  style={{
                    borderRadius: 16,
                    gridColumn: view === "board" ? p.status + 1 : undefined,
                    marginLeft: bar ? `${(p.start / 12) * 100}%` : 0,
                    width: bar ? `${(p.span / 12) * 100}%` : "100%",
                  }}
                  className={`relative flex min-w-0 overflow-hidden transition-colors duration-300 ${bar ? `${p.tone} text-white` : "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200"} ${tall ? "flex-col justify-between p-3 sm:p-4" : "items-center gap-3 px-3"}`}
                >
                  <motion.span layout="position" transition={move} className={`flex-none items-center justify-center rounded-lg text-[11px] font-bold transition-colors duration-300 ${bar ? "hidden h-7 w-7 bg-white/20 text-white sm:flex" : `flex h-8 w-8 ${p.tone} text-white`}`}>
                    {p.initials}
                  </motion.span>
                  <motion.span layout="position" transition={move} className={`block min-w-0 ${tall ? "w-full" : "flex-1"}`}>
                    <span className="block truncate text-sm font-semibold">{p.name}</span>
                    {!bar && <span className="block truncate text-xs text-zinc-600">{p.client}</span>}
                  </motion.span>
                  {view === "grid" && (
                    <motion.span aria-hidden="true" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="absolute right-3 top-3 flex items-center gap-2 font-mono text-xs text-zinc-600 sm:right-4 sm:top-4">
                      <span className="hidden h-1.5 w-14 overflow-hidden rounded-full bg-zinc-100 sm:block">
                        <span className={`block h-full rounded-full ${p.tone}`} style={{ width: `${p.done}%` }} />
                      </span>
                      {p.done}%
                    </motion.span>
                  )}
                  {view === "list" && (
                    <motion.span initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.25 }} className="flex-none rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-700">
                      {statusNames[p.status]}
                    </motion.span>
                  )}
                </motion.li>
              );
            })}
          </ul>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/segmented-control-with-spring-pill-and-morphing-views.json

When to use it

How to customize it

Accessibility

Questions

How does the pill slide between segments?

The pill is only rendered inside the selected button, and it has a `layoutId`. When the selection changes, Framer Motion sees the same id in a new place and animates the element from the old button to the new one with a spring.

How do the cards move between layouts?

Each card has the `layout` prop. Changing the view changes the grid classes on the list, so the browser places every card somewhere new. Framer Motion measures before and after and animates the difference with transforms, so no layout property is animated.

How is the board built from one grid?

In board view every card is given a `grid-column` from its status, and the grid uses `grid-auto-flow: row dense`, which packs each card into the first free row of its own column.

Segmented control or tabs?

Use a segmented control when the options are views or modes of the same content and there are five or fewer. Use tabs when each option leads to different content with its own heading.

Why does the text not stretch while a card resizes?

The avatar and the text inside each card use `layout="position"`, which moves them without scaling them, and the card radius is set through `style` so it stays round during the animation.

Related: Sun to Moon Theme Toggle with Circular Reveal, Underline Tabs with Icons and Three Panels, Product Grid with Filter Chips.

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