Morph Tab Bar
NewInstallation
npx shadcn@latest add https://uibrik.com/r/morph-tab-bar.jsonInstall the packages
npm install @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/morph-tab-bar/morph-tab-bar.tsx"use client"; import { useLayoutEffect, useRef, useState, type ComponentType, type KeyboardEvent, type PointerEvent } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { IconBell, IconBellFilled, IconHeart, IconHeartFilled, IconHome, IconHomeFilled, IconSearch, IconUser, IconUserFilled, IconSearchFilled, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; // Morph tab bar. The bar is drawn twice: underneath, every tab idle (outline // glyph, muted); on top, every tab active (filled glyph, full colour) on the // pill's fill, clipped by `clip-path` to the pill's shape. Moving the pill is // only moving the clip, so an icon turns from outline to filled exactly where // the pill's edge crosses it, rather than fading as a whole. // // The pill doesn't slide, it pours: the edge facing the new tab leaves first // and the trailing edge catches up, so it stretches across the gap and settles // back to size, thinning a little while stretched like a drop pulled long. // // Like the iOS tab bar, the pill can also be grabbed: press anywhere on the bar // and it swells, follows the finger (stretching with its speed, since each edge // chases the finger on its own spring), and on release drops into the nearest // tab. A tap is just a very short drag. const PAD = 4; // inset of the pill from the bar's edge, px const OUTSET = 8; // room around the bar for the pill to swell into while held, px type IconType = ComponentType<{ size?: number; stroke?: number; className?: string }>; export type MorphTab = { label: string; /** Outline glyph, shown while the tab is idle. */ icon: IconType; /** Filled glyph, shown while the tab is active. Defaults to `icon`. */ activeIcon?: IconType; }; const TABS: MorphTab[] = [ { label: "Home", icon: IconHome, activeIcon: IconHomeFilled }, { label: "Search", icon: IconSearch, activeIcon: IconSearchFilled }, { label: "Inbox", icon: IconBell, activeIcon: IconBellFilled }, { label: "Saved", icon: IconHeart, activeIcon: IconHeartFilled }, { label: "Profile", icon: IconUser, activeIcon: IconUserFilled }, ]; export type MorphTabBarProps = { tabs?: readonly MorphTab[]; /** Active tab on mount (uncontrolled). */ defaultValue?: number; /** Active tab (controlled). */ value?: number; onChange?: (index: number) => void; /** Width of one tab, px. */ tabWidth?: number; /** Small label under each icon. */ labels?: boolean; /** Raised: a light pill on a tinted bar. Solid: an inverted pill. */ indicator?: "raised" | "solid"; /** Seconds the trailing edge takes to arrive. */ duration?: number; /** How far the trailing edge lags the leading one, 0–1. 0 slides rigidly. */ stretch?: number; /** Press and drag the pill across the tabs. */ draggable?: boolean; /** How much the pill grows while held. */ lift?: number; className?: string; }; export function MorphTabBar({ tabs = TABS, defaultValue = 0, value, onChange, tabWidth = 64, labels = false, indicator = "raised", duration = 0.5, stretch = 0.5, draggable = true, lift = 1.12, className, }: MorphTabBarProps) { const reduceMotion = useReducedMotion(); const [inner, setInner] = useState(defaultValue); const active = value ?? inner; const bar = useRef<HTMLDivElement>(null); const buttons = useRef<(HTMLButtonElement | null)[]>([]); // The tab under the held pill, or null while nothing is held. const [over, setOver] = useState<number | null>(null); const held = over !== null; const height = labels ? 56 : 44; const count = tabs.length; // The pill is its two edges, animated separately. const left = useMotionValue(PAD + active * tabWidth); const right = useMotionValue(PAD + (active + 1) * tabWidth); const scale = useMotionValue(1); // The pill as an inset() on the top layer, which overhangs the bar by OUTSET. const barWidth = count * tabWidth + PAD * 2; const clipPath = useTransform(() => { const l = left.get(); const r = right.get(); const s = scale.get(); // Thin a little in proportion to how far the pill is stretched past one tab. const thin = height * Math.min(0.06, (Math.max(0, r - l - tabWidth) / tabWidth) * 0.04); const swellY = ((s - 1) * height) / 2; const swellX = ((s - 1) * tabWidth) / 2; const y = OUTSET + PAD - swellY + thin; return `inset(${y}px ${OUTSET + barWidth - r - swellX}px ${y}px ${OUTSET + l - swellX}px round 9999px)`; }); // Sends both edges toward `toLeft`, the edge facing the motion first. const pour = (toLeft: number, d: number) => { const toRight = toLeft + tabWidth; if (reduceMotion) { left.jump(toLeft); right.jump(toRight); return; } const goingRight = toLeft > left.get(); const lead = { type: "spring", duration: d * (1 - stretch * 0.6), bounce: 0 } as const; const trail = { type: "spring", duration: d, bounce: 0 } as const; animate(left, toLeft, goingRight ? trail : lead); animate(right, toRight, goingRight ? lead : trail); }; // A new active tab pours the pill across; a new tab width just re-seats it. const last = useRef({ active, tabWidth }); useLayoutEffect(() => { const prev = last.current; last.current = { active, tabWidth }; if (prev.active === active && prev.tabWidth === tabWidth) return; const toLeft = PAD + active * tabWidth; const goingRight = toLeft > left.get(); if (reduceMotion || prev.tabWidth !== tabWidth) { left.jump(toLeft); right.jump(toLeft + tabWidth); return; } const lead = { type: "spring", duration: duration * (1 - stretch * 0.6), bounce: 0 } as const; const trail = { type: "spring", duration, bounce: 0 } as const; animate(left, toLeft, goingRight ? trail : lead); animate(right, toLeft + tabWidth, goingRight ? lead : trail); }, [active, tabWidth, reduceMotion, duration, stretch, left, right]); const select = (i: number) => { if (i === active) return; if (value === undefined) setInner(i); onChange?.(i); }; // ─── Dragging ────────────────────────────────────────────────────────────── // Pill centre under the finger, held to the bar with a little rubber-band past either end. const follow = (clientX: number) => { const rect = bar.current!.getBoundingClientRect(); const x = clientX - rect.left; const min = PAD + tabWidth / 2; const max = PAD + count * tabWidth - tabWidth / 2; const band = (d: number) => 10 * (1 - Math.exp(-d / 40)); const centre = x < min ? min - band(min - x) : x > max ? max + band(x - max) : x; pour(centre - tabWidth / 2, duration * 0.5); setOver(Math.max(0, Math.min(count - 1, Math.round((centre - min) / tabWidth)))); }; const release = (to: number) => { setOver(null); animate(scale, 1, reduceMotion ? { duration: 0 } : { type: "spring", duration: 0.35, bounce: 0 }); pour(PAD + to * tabWidth, duration); select(to); }; const onPointerDown = (e: PointerEvent<HTMLDivElement>) => { if (!draggable || e.button !== 0) return; e.currentTarget.setPointerCapture(e.pointerId); animate(scale, lift, reduceMotion ? { duration: 0 } : { type: "spring", duration: 0.3, bounce: 0 }); follow(e.clientX); }; const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (held && e.currentTarget.hasPointerCapture(e.pointerId)) follow(e.clientX); }; // ─── Keyboard ────────────────────────────────────────────────────────────── const onKeyDown = (e: KeyboardEvent) => { const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; let next: number | undefined; if (step) next = (active + step + count) % count; if (e.key === "Home") next = 0; if (e.key === "End") next = count - 1; if (next === undefined) return; e.preventDefault(); select(next); buttons.current[next]?.focus(); }; const solid = indicator === "solid"; // One tab's glyph and label, idle (outline) or active (filled). const cell = (i: number, on: boolean) => { const { label, icon: Icon, activeIcon: ActiveIcon = Icon } = tabs[i]; return ( <span key={`${i}-${label}`} className="flex shrink-0 flex-col items-center justify-center gap-0.5" style={{ width: tabWidth, height }}> {on ? <ActiveIcon size={22} /> : <Icon size={22} stroke={1.75} />} {labels && <span className="text-[11px] leading-none font-medium tracking-[-0.01em]">{label}</span>} </span> ); }; return ( <div ref={bar} role="tablist" aria-orientation="horizontal" onKeyDown={onKeyDown} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={() => held && release(over)} onPointerCancel={() => held && release(active)} className={cn( "relative flex rounded-full bg-muted has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-foreground/20", draggable && "touch-pan-y select-none", className, )} style={{ padding: PAD, height: height + PAD * 2 }} > {tabs.map(({ label }, i) => ( <button key={`${i}-${label}`} ref={(el) => { buttons.current[i] = el; }} role="tab" type="button" aria-selected={i === active} aria-label={label} tabIndex={i === active ? 0 : -1} // Pointer presses go through the drag; this catches Enter and Space. onClick={(e) => (!draggable || e.detail === 0) && select(i)} className="flex shrink-0 cursor-pointer rounded-full text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground" > {cell(i, false)} </button> ))} <motion.div aria-hidden className={cn("pointer-events-none absolute flex", solid ? "bg-foreground text-background" : "bg-background text-foreground dark:bg-neutral-800")} style={{ inset: -OUTSET, padding: OUTSET + PAD, clipPath }} > {tabs.map((_, i) => cell(i, true))} </motion.div> </div> ); }
Adds the files to
components/uibrik/morph-tab-bar and installs @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/morph-tab-bar.Props
MorphTabBar- indicator
- Type "raised" | "solid"
- "raised"
- A light pill on a tinted bar, or an inverted pill.
- labels
- Type boolean
- false
- Small label under each icon.
- tabWidth
- Type number
- 64
- Width of one tab.
- duration
- Type number
- 0.5
- How long the trailing edge takes to arrive.
- draggable
- Type boolean
- true
- Press and drag the pill across the tabs.
- lift
- Type number
- 1.12
- How much the pill grows while held.
- stretch
- Type number
- 0.5
- How far the trailing edge lags the leading one. 0 slides rigidly.
- tabs
- Type { label: string; icon: Icon; activeIcon?: Icon }[]
- Home, Search, Inbox, Saved, Profile
- Tabs in order. activeIcon is the filled glyph shown while active.
- defaultValue
- Type number
- 0
- Active tab on mount (uncontrolled).
- value
- Type number
- –
- Active tab (controlled).
- onChange
- Type (index: number) => void
- –
- Called with the tab picked by click or arrow keys.
- className
- Type string
- –
- Classes for the bar.