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 allPalak dal20 min
Lemon rice15 min
Search
Search 12,000 recipes
CurriesBreadsSaladsDesserts
New recipeDrafts
Add a cover photo
Publish recipeInbox
KS
Kabir saved your Grandma’s rasam2 min
MI
Meera commented: “Made this tonight!”1 h
AR
Ananya started following you3 h
FollowDV
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.