Tab Bar Icon Select
The chosen icon fills and lifts while its label brightens, and the icon you left releases.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Tab Bar Icon Select
*
* Selecting a section fills its icon and brings its label up to full
* strength, while the icon you left releases. Nothing travels between
* the two — each one answers for itself.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the bar reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `defaultIndex`.
* Press a tab, or focus one and use the arrow keys.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TabBarIconSelectProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Section selected on first render. */
defaultIndex?: number;
/** Accent applied to the selected icon and label. */
accent?: string;
/** Notified with the newly selected index. */
onChange?: (index: number) => void;
};
type VariantConfig = {
/** Size a resting icon sits at, as a fraction of the selected one. */
restScale: number;
/** How far the selected icon rises, in px. */
rise: number;
/** Seconds for the solid glyph to replace the outline. */
fillFade: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the pop belongs to the icon, which is a glyph, never to
// the label, which is text — a tab label that changes size on every press
// is the cheapest movement in mobile chrome. Even on the icon the spring
// stays at or above a 0.8 damping ratio: this control is pressed hundreds
// of times a day and a bouncing icon wears out in an afternoon. Variants
// change how far the icon travels, never how often it lands.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Only the fill changes, with the faintest size difference. For utility
// apps where the bar should be furniture.
subtle: {
restScale: 0.99,
rise: 0,
fillFade: 0.07,
spring: { type: "spring", stiffness: 840, damping: 48 },
},
// A small, single pop as the glyph fills. All-purpose.
default: {
restScale: 0.9,
rise: 1.5,
fillFade: 0.16,
spring: { type: "spring", stiffness: 600, damping: 40 },
},
// A larger pop with a visible lift — for a consumer app whose tab bar
// is part of its character.
playful: {
restScale: 0.77,
rise: 4.7,
fillFade: 0.2,
spring: { type: "spring", stiffness: 440, damping: 34 },
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const HOME_PATH = "M9 2.9 15.3 7.9v7.2h-4.5v-4.1H7.2v4.1H2.7V7.9z";
const BELL_PATH = "M9 2.6a4.1 4.1 0 0 1 4.1 4.1v3.1l1.2 2.1H3.7l1.2-2.1V6.7A4.1 4.1 0 0 1 9 2.6z";
const HEAD_PATH = "M9 3.1a2.7 2.7 0 1 1 0 5.4 2.7 2.7 0 0 1 0-5.4z";
const SHOULDER_PATH = "M3.5 15.4c0-3.1 2.5-4.7 5.5-4.7s5.5 1.6 5.5 4.7z";
const SECTIONS = [
{
label: "Home",
heading: "Today",
body: "3 tasks due · 1 review waiting on you",
outline: <path d={HOME_PATH} />,
solid: <path d={HOME_PATH} fill="currentColor" stroke="none" />,
},
{
label: "Search",
heading: "Search",
body: "Recent: invoices, seat usage, eu-west",
outline: (
<>
<circle cx="8.1" cy="8.1" r="4.4" />
<path d="m11.6 11.6 3.2 3.2" />
</>
),
solid: (
<>
<circle cx="8.1" cy="8.1" r="4.4" fill="currentColor" stroke="none" />
<path d="m11.6 11.6 3.2 3.2" strokeWidth="2" />
</>
),
},
{
label: "Activity",
heading: "Activity",
body: "18 events today across 4 projects",
outline: (
<>
<path d={BELL_PATH} />
<path d="M7.4 14.4a1.7 1.7 0 0 0 3.2 0" />
</>
),
solid: (
<>
<path d={BELL_PATH} fill="currentColor" stroke="none" />
<path d="M7.4 14.4a1.7 1.7 0 0 0 3.2 0" strokeWidth="1.9" />
</>
),
},
{
label: "Profile",
heading: "Your account",
body: "Owner · 2 workspaces · eu-west",
outline: (
<>
<path d={HEAD_PATH} />
<path d={SHOULDER_PATH} />
</>
),
solid: (
<>
<path d={HEAD_PATH} fill="currentColor" stroke="none" />
<path d={SHOULDER_PATH} fill="currentColor" stroke="none" />
</>
),
},
] as const;
export default function TabBarIconSelect({
variant = "default",
defaultIndex = 0,
accent = ACCENT,
onChange,
}: TabBarIconSelectProps) {
const [active, setActive] = useState(defaultIndex);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const uid = useId();
const select = (index: number) => {
setActive(index);
onChange?.(index);
};
// Reduced motion: the fill and the label strength still say which
// section is open — only the pop and the lift are dropped.
const glyphTransition = reduceMotion ? { duration: 0 } : cfg.spring;
const fade = { duration: reduceMotion ? 0 : cfg.fillFade, ease: "easeOut" as const };
return (
<div
style={{
display: "flex",
flexDirection: "column",
width: 302,
height: 322,
borderRadius: 22,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div
id={`${uid}-panel`}
role="tabpanel"
aria-labelledby={`${uid}-tab-${active}`}
style={{ flex: 1, padding: "20px 18px" }}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.1 : 0.14, ease: "easeOut" }}
>
<div style={{ fontSize: 17, fontWeight: 680, letterSpacing: "-0.01em" }}>
{SECTIONS[active].heading}
</div>
<div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 4 }}>
{SECTIONS[active].body}
</div>
</motion.div>
</AnimatePresence>
<div style={{ marginTop: 16 }}>
{[0, 1, 2].map((row) => (
<div
key={row}
aria-hidden
style={{
height: 34,
marginBottom: 8,
borderRadius: 10,
background: tone(7),
border: `1px solid ${tone(9)}`,
}}
/>
))}
</div>
</div>
{/* The bar is the bottom edge of this panel rather than the
viewport's, so the pattern drops into a card. For a real app,
make this `position: fixed; bottom: 0; left: 0; right: 0` and add
the safe-area inset to its bottom padding. */}
<div
role="tablist"
aria-label="Sections"
style={{
display: "flex",
padding: "7px 6px 9px",
borderTop: `1px solid ${tone(12)}`,
background: tone(4),
}}
>
{SECTIONS.map((section, index) => {
const isActive = index === active;
return (
<button
key={section.label}
type="button"
role="tab"
id={`${uid}-tab-${index}`}
aria-selected={isActive}
aria-controls={`${uid}-panel`}
// Roving tabindex: the bar is one stop in the page's tab
// order, arrow keys move within it.
tabIndex={isActive ? 0 : -1}
onClick={() => select(index)}
onKeyDown={(event) => {
const last = SECTIONS.length - 1;
let next = -1;
if (event.key === "ArrowRight") next = active === last ? 0 : active + 1;
else if (event.key === "ArrowLeft") next = active === 0 ? last : active - 1;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = last;
if (next === -1) return;
event.preventDefault();
select(next);
const sibling = event.currentTarget.parentElement?.children[next];
if (sibling instanceof HTMLElement) sibling.focus();
}}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
padding: "6px 0 4px",
border: 0,
background: "none",
color: "inherit",
fontFamily: "inherit",
cursor: "pointer",
}}
>
<motion.span
initial={false}
animate={{
scale: isActive ? 1 : cfg.restScale,
y: isActive ? -cfg.rise : 0,
}}
transition={glyphTransition}
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 30,
height: 26,
}}
>
{/* The wash stays under its own icon: nothing slides from
one tab to the next, so the tab you left is plainly
released rather than handing something over. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: isActive ? 1 : 0 }}
transition={fade}
style={{
position: "absolute",
inset: "-2px 0",
borderRadius: 9,
// Mixed from the accent, not from `currentColor`: this
// is the selection's own color and should follow the
// accent if the app changes it.
background: `color-mix(in srgb, ${accent} 16%, transparent)`,
}}
/>
{/* Two glyphs at the same size, cross-faded. Morphing an
outline into a solid means interpolating path data,
which distorts the shape halfway; swapping opacity is
exact at both ends and free in between. */}
<motion.svg
aria-hidden
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ opacity: isActive ? 0 : 0.55 }}
transition={fade}
style={{ position: "absolute" }}
>
{section.outline}
</motion.svg>
<motion.svg
aria-hidden
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke={accent}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
color={accent}
initial={false}
animate={{ opacity: isActive ? 1 : 0 }}
transition={fade}
style={{ position: "absolute" }}
>
{section.solid}
</motion.svg>
</motion.span>
{/* The label brightens by opacity alone. It never changes
size, weight or color: a weight swap reflows the word and
makes the whole bar twitch, and a color that cannot be
interpolated from the inherited one would snap while
everything around it eases. */}
<motion.span
initial={false}
animate={{ opacity: isActive ? 1 : 0.5 }}
transition={fade}
style={{
fontSize: 10.5,
fontWeight: 600,
letterSpacing: "0.01em",
}}
>
{section.label}
</motion.span>
</button>
);
})}
</div>
</div>
);
}About this pattern
Selection in a bottom bar, where nothing is supposed to travel. Each icon answers for itself: the outline cross-fades to a solid glyph, a soft wash comes up beneath it, and the one you left releases at the same rate. The glyph swap is two shapes at the same size traded by opacity rather than one path morphed into another, because interpolating path data distorts the silhouette exactly halfway through. The pop belongs to the icon; the label only brightens, since it is text and a label that changes size — or weight, which reflows the word — makes the whole bar twitch on every press. Even the icon's spring stays near critical damping: this control gets pressed hundreds of times a day, and a bouncing one wears out in an afternoon.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
The selected section's glyph is solid and tinted while the others stay hollow.
Related patterns
- Mobile Menu FullscreenA hamburger becomes a close glyph as a full-screen menu wipes in with staggered links.
- Floating Action ExpandThe round action button opens into a labelled list of actions that climb out of it, nearest first.
- Sidebar CollapseA navigation rail narrows to icons, with labels leaving before the width closes so text is never squeezed.