Skip to content
All Taste components

Animated screens

Morphing tab bar

A five-tab app whose active pill slides and stretches between tabs while the page cross-fades.

5 tabs · sliding pill · icon bounce · cross-fade

Tadka

Good evening, Riya — here’s tonight.

Dinner · 35 min · serves 4Paneer butter masala

Quick lunches

See all
Palak dal20 min
Lemon rice15 min
Search
QuickVeganBreakfastBaking
CurriesBreadsSaladsDesserts
New recipeDrafts
Add a cover photo
Publish recipe
Inbox
KS
Kabir saved your Grandma’s rasam2 min
MI
Meera commented: “Made this tonight!”1 h
AR
Ananya started following you3 h
Follow
DV
Dev tried your lemon riceYesterday
Profile
RMRiya Menon@riyacooks · Kochi
48Recipes
2.1kFollowers
312Saved
// Morphing tab bar: the active pill slides to the tab you pick and stretches on the way,
// the new icon bounces, and the page cross-fades. Mobile-first on the Taste preset.
"use client";

import { useState } from "react";
import { Home, MessageCircle, PlusSquare, Search, User } from "lucide-react";
import { cn } from "@/lib/utils";

const tabs = [
  { id: "home", label: "Home", icon: Home },
  { id: "search", label: "Search", icon: Search },
  { id: "create", label: "Create", icon: PlusSquare },
  { id: "inbox", label: "Inbox", icon: MessageCircle },
  { id: "profile", label: "Profile", icon: User },
] as const;

type TabId = (typeof tabs)[number]["id"];

export default function AppShell({ pages }: { pages: Record<TabId, React.ReactNode> }) {
  const [active, setActive] = useState(0);
  const [hop, setHop] = useState(0); // bumps on every change so the pill morph replays

  function pick(i: number) {
    if (i === active) return;
    setActive(i);
    setHop((h) => h + 1);
  }

  return (
    <div className="mx-auto flex h-dvh max-w-md flex-col bg-bg font-sans text-fg">
      <main key={active} className="min-h-0 flex-1 overflow-y-auto animate-[page-in_.35s_cubic-bezier(.2,.7,.1,1)_both] motion-reduce:animate-none">
        {pages[tabs[active].id]}
      </main>

      <nav aria-label="Main" className="relative grid shrink-0 grid-cols-5 border-t border-border bg-bg px-1.5 pb-6 pt-1.5">
        {/* Pill: slides between tabs and squashes on the way */}
        <span
          aria-hidden
          className="absolute left-1.5 top-1.5 grid h-[30px] w-[calc((100%-12px)/5)] place-items-center transition-transform duration-500 ease-taste motion-reduce:transition-none"
          style={{ transform: "translateX(" + active * 100 + "%)" }}
        >
          <span key={hop} className={cn("h-[30px] w-12 rounded-full bg-primary-soft", hop > 0 && "animate-[pill-morph_.5s_cubic-bezier(.2,.7,.1,1)] motion-reduce:animate-none")} />
        </span>
        {tabs.map((t, i) => {
          const on = i === active;
          const Icon = t.icon;
          return (
            <button
              key={t.id}
              type="button"
              aria-current={on ? "page" : undefined}
              onClick={() => pick(i)}
              className={cn("relative grid justify-items-center gap-[3px] text-[10.5px] font-medium transition-colors", on ? "text-fg" : "text-fg-subtle hover:text-fg-muted")}
            >
              <span key={on ? "on" + hop : "off"} className={cn("grid h-[30px] place-items-center", on ? "text-primary-soft-fg" : "", on && hop > 0 && "animate-[tab-bounce_.55s_cubic-bezier(.34,1.56,.64,1)] motion-reduce:animate-none")}>
                <Icon className="size-[22px]" />
              </span>
              {t.label}
            </button>
          );
        })}
      </nav>
    </div>
  );
}

/* globals.css
@keyframes pill-morph { 40% { transform: scale(1.5, .8); } }
@keyframes tab-bounce { 0% { transform: none; } 35% { transform: translateY(-5px) scale(1.14); } 100% { transform: none; } }
@keyframes page-in { from { opacity: 0; transform: translateY(8px); } }
*/

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.