Morph Tabs

Fluid tabs with a shared-layout active pill that morphs between items, plus optional layered glow polish.

No glow
Large tabs with team roster dummy content

Team members

8 online, 3 pending invites

1"use client";
2
3import * as React from "react";
4import { cva, type VariantProps } from "class-variance-authority";
5import { motion, useReducedMotion } from "framer-motion";
6import { cn } from "@/lib/utils";
7import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
8
9const morphTabsVariants = cva(
10 "inline-flex items-center gap-1 rounded-xl border border-border/70 bg-muted/40 p-1",
11 {
12 variants: {
13 size: {
14 sm: "h-9",
15 default: "h-11",
16 lg: "h-12",
17 },
18 },
19 defaultVariants: {
20 size: "default",
21 },
22 },
23);
24
25const morphTabsTriggerVariants = cva(
26 "relative z-10 inline-flex items-center justify-center rounded-lg px-3 font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
27 {
28 variants: {
29 size: {
30 sm: "h-7 text-xs",
31 default: "h-9 text-sm",
32 lg: "h-10 text-sm",
33 },
34 active: {
35 true: "text-foreground",
36 false: "text-muted-foreground hover:text-foreground/90",
37 },
38 },
39 defaultVariants: {
40 size: "default",
41 active: false,
42 },
43 },
44);
45
46export interface MorphTabsProps extends VariantProps<typeof morphTabsVariants> {
47 tabs: string[];
48 active?: string;
49 defaultActive?: string;
50 onActiveChange?: (value: string) => void;
51 panels?: Record<string, React.ReactNode>;
52 panelClassName?: string;
53 glow?: boolean;
54 glowIntensity?: "subtle" | "default" | "strong";
55 glowColor?: string;
56 className?: string;
57}
58
59export function MorphTabs({
60 tabs,
61 active,
62 defaultActive,
63 onActiveChange,
64 panels,
65 panelClassName,
66 glow = true,
67 glowIntensity = "default",
68 glowColor = "rgb(129 140 248)",
69 className,
70 size,
71}: MorphTabsProps) {
72 const fallbackTab = tabs[0] ?? "";
73 const [internalActive, setInternalActive] = React.useState(
74 defaultActive ?? active ?? fallbackTab,
75 );
76 const controlled = active !== undefined;
77 const currentActive = controlled ? active : internalActive;
78 const activeTab = tabs.includes(currentActive) ? currentActive : fallbackTab;
79 const tabsListRef = React.useRef<HTMLDivElement>(null);
80 const reduceMotion = useReducedMotion();
81 const [pillStyle, setPillStyle] = React.useState<{
82 left: number;
83 width: number;
84 }>({
85 left: 0,
86 width: 0,
87 });
88
89 React.useEffect(() => {
90 if (!controlled && activeTab !== internalActive) {
91 setInternalActive(activeTab);
92 }
93 }, [controlled, activeTab, internalActive]);
94
95 const setActive = React.useCallback(
96 (value: string) => {
97 if (!controlled) {
98 setInternalActive(value);
99 }
100 onActiveChange?.(value);
101 },
102 [controlled, onActiveChange],
103 );
104
105 const updatePillPosition = React.useCallback(() => {
106 const tabsListEl = tabsListRef.current;
107 if (!tabsListEl) return;
108 const activeEl = tabsListEl.querySelector<HTMLElement>(
109 '[role="tab"][data-state="active"]',
110 );
111 if (!activeEl) return;
112
113 setPillStyle({
114 left: activeEl.offsetLeft,
115 width: activeEl.offsetWidth,
116 });
117 }, []);
118
119 React.useEffect(() => {
120 updatePillPosition();
121 }, [updatePillPosition, tabs, size, activeTab]);
122
123 React.useEffect(() => {
124 window.addEventListener("resize", updatePillPosition);
125 return () => window.removeEventListener("resize", updatePillPosition);
126 }, [updatePillPosition]);
127
128 const glowByIntensity = {
129 subtle: {
130 haloOpacity: 0.2,
131 haloBlur: 14,
132 ringOpacity: 0.12,
133 ringBlur: 3,
134 },
135 default: {
136 haloOpacity: 0.28,
137 haloBlur: 20,
138 ringOpacity: 0.16,
139 ringBlur: 4,
140 },
141 strong: {
142 haloOpacity: 0.38,
143 haloBlur: 28,
144 ringOpacity: 0.24,
145 ringBlur: 5,
146 },
147 } as const;
148 const glowStyle = glowByIntensity[glowIntensity];
149
150 if (!tabs.length) {
151 return null;
152 }
153
154 return (
155 <Tabs value={activeTab} onValueChange={setActive} className={className}>
156 <TabsList
157 ref={tabsListRef}
158 className={cn(
159 "relative isolate gap-1 p-1",
160 morphTabsVariants({ size }),
161 "h-auto w-full justify-start",
162 )}
163 >
164 <motion.div
165 aria-hidden
166 animate={{ left: pillStyle.left, width: pillStyle.width }}
167 transition={{ type: "spring", stiffness: 450, damping: 34 }}
168 className="absolute bottom-1 top-1 z-0 rounded-lg border border-border/70 bg-background shadow-sm"
169 >
170 {glow && (
171 <>
172 <motion.div
173 aria-hidden
174 className="pointer-events-none absolute inset-0 rounded-lg"
175 style={{
176 boxShadow: `0 0 ${glowStyle.haloBlur}px ${glowStyle.haloBlur / 2}px ${glowColor}`,
177 }}
178 animate={
179 reduceMotion
180 ? { opacity: glowStyle.haloOpacity }
181 : {
182 opacity: [
183 glowStyle.haloOpacity * 0.75,
184 glowStyle.haloOpacity,
185 glowStyle.haloOpacity * 0.75,
186 ],
187 }
188 }
189 transition={{
190 duration: 1.8,
191 ease: "easeInOut",
192 repeat: reduceMotion ? 0 : Infinity,
193 }}
194 />
195 <motion.div
196 aria-hidden
197 className="pointer-events-none absolute inset-0 rounded-lg"
198 style={{
199 boxShadow: `inset 0 0 ${glowStyle.ringBlur}px ${glowColor}`,
200 }}
201 animate={{ opacity: glowStyle.ringOpacity }}
202 transition={{ duration: reduceMotion ? 0 : 0.2 }}
203 />
204 </>
205 )}
206 </motion.div>
207
208 {tabs.map((tab) => {
209 const isActive = tab === activeTab;
210 return (
211 <TabsTrigger
212 key={tab}
213 value={tab}
214 className={cn(
215 "relative z-10 border-0 bg-transparent shadow-none ring-0 after:hidden focus-visible:ring-2 focus-visible:ring-ring/60",
216 morphTabsTriggerVariants({ size, active: isActive }),
217 "data-[state=active]:border-0 data-[state=active]:!bg-transparent data-[state=active]:!shadow-none dark:data-[state=active]:!bg-transparent dark:data-[state=active]:!border-transparent",
218 )}
219 >
220 <span>{tab}</span>
221 </TabsTrigger>
222 );
223 })}
224 </TabsList>
225
226 {panels &&
227 tabs.map((tab) => (
228 <TabsContent
229 key={tab}
230 value={tab}
231 className="mt-3 data-[state=inactive]:hidden"
232 >
233 {tab === activeTab && (
234 <motion.div
235 key={activeTab}
236 initial={{ opacity: 0, y: 6 }}
237 animate={{ opacity: 1, y: 0 }}
238 transition={{ duration: 0.8, ease: "easeInOut" }}
239 className={cn(
240 "rounded-xl border border-border/70 bg-card p-4 text-sm",
241 panelClassName,
242 )}
243 >
244 {panels[tab]}
245 </motion.div>
246 )}
247 </TabsContent>
248 ))}
249 </Tabs>
250 );
251}
252
253export { morphTabsVariants, morphTabsTriggerVariants };
Subtle glow
Subtle glow with default color

MRR

$42,380

Active users

12,481

Churn

1.8%

1"use client";
2
3import * as React from "react";
4import { cva, type VariantProps } from "class-variance-authority";
5import { motion, useReducedMotion } from "framer-motion";
6import { cn } from "@/lib/utils";
7import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
8
9const morphTabsVariants = cva(
10 "inline-flex items-center gap-1 rounded-xl border border-border/70 bg-muted/40 p-1",
11 {
12 variants: {
13 size: {
14 sm: "h-9",
15 default: "h-11",
16 lg: "h-12",
17 },
18 },
19 defaultVariants: {
20 size: "default",
21 },
22 },
23);
24
25const morphTabsTriggerVariants = cva(
26 "relative z-10 inline-flex items-center justify-center rounded-lg px-3 font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
27 {
28 variants: {
29 size: {
30 sm: "h-7 text-xs",
31 default: "h-9 text-sm",
32 lg: "h-10 text-sm",
33 },
34 active: {
35 true: "text-foreground",
36 false: "text-muted-foreground hover:text-foreground/90",
37 },
38 },
39 defaultVariants: {
40 size: "default",
41 active: false,
42 },
43 },
44);
45
46export interface MorphTabsProps extends VariantProps<typeof morphTabsVariants> {
47 tabs: string[];
48 active?: string;
49 defaultActive?: string;
50 onActiveChange?: (value: string) => void;
51 panels?: Record<string, React.ReactNode>;
52 panelClassName?: string;
53 glow?: boolean;
54 glowIntensity?: "subtle" | "default" | "strong";
55 glowColor?: string;
56 className?: string;
57}
58
59export function MorphTabs({
60 tabs,
61 active,
62 defaultActive,
63 onActiveChange,
64 panels,
65 panelClassName,
66 glow = true,
67 glowIntensity = "default",
68 glowColor = "rgb(129 140 248)",
69 className,
70 size,
71}: MorphTabsProps) {
72 const fallbackTab = tabs[0] ?? "";
73 const [internalActive, setInternalActive] = React.useState(
74 defaultActive ?? active ?? fallbackTab,
75 );
76 const controlled = active !== undefined;
77 const currentActive = controlled ? active : internalActive;
78 const activeTab = tabs.includes(currentActive) ? currentActive : fallbackTab;
79 const tabsListRef = React.useRef<HTMLDivElement>(null);
80 const reduceMotion = useReducedMotion();
81 const [pillStyle, setPillStyle] = React.useState<{
82 left: number;
83 width: number;
84 }>({
85 left: 0,
86 width: 0,
87 });
88
89 React.useEffect(() => {
90 if (!controlled && activeTab !== internalActive) {
91 setInternalActive(activeTab);
92 }
93 }, [controlled, activeTab, internalActive]);
94
95 const setActive = React.useCallback(
96 (value: string) => {
97 if (!controlled) {
98 setInternalActive(value);
99 }
100 onActiveChange?.(value);
101 },
102 [controlled, onActiveChange],
103 );
104
105 const updatePillPosition = React.useCallback(() => {
106 const tabsListEl = tabsListRef.current;
107 if (!tabsListEl) return;
108 const activeEl = tabsListEl.querySelector<HTMLElement>(
109 '[role="tab"][data-state="active"]',
110 );
111 if (!activeEl) return;
112
113 setPillStyle({
114 left: activeEl.offsetLeft,
115 width: activeEl.offsetWidth,
116 });
117 }, []);
118
119 React.useEffect(() => {
120 updatePillPosition();
121 }, [updatePillPosition, tabs, size, activeTab]);
122
123 React.useEffect(() => {
124 window.addEventListener("resize", updatePillPosition);
125 return () => window.removeEventListener("resize", updatePillPosition);
126 }, [updatePillPosition]);
127
128 const glowByIntensity = {
129 subtle: {
130 haloOpacity: 0.2,
131 haloBlur: 14,
132 ringOpacity: 0.12,
133 ringBlur: 3,
134 },
135 default: {
136 haloOpacity: 0.28,
137 haloBlur: 20,
138 ringOpacity: 0.16,
139 ringBlur: 4,
140 },
141 strong: {
142 haloOpacity: 0.38,
143 haloBlur: 28,
144 ringOpacity: 0.24,
145 ringBlur: 5,
146 },
147 } as const;
148 const glowStyle = glowByIntensity[glowIntensity];
149
150 if (!tabs.length) {
151 return null;
152 }
153
154 return (
155 <Tabs value={activeTab} onValueChange={setActive} className={className}>
156 <TabsList
157 ref={tabsListRef}
158 className={cn(
159 "relative isolate gap-1 p-1",
160 morphTabsVariants({ size }),
161 "h-auto w-full justify-start",
162 )}
163 >
164 <motion.div
165 aria-hidden
166 animate={{ left: pillStyle.left, width: pillStyle.width }}
167 transition={{ type: "spring", stiffness: 450, damping: 34 }}
168 className="absolute bottom-1 top-1 z-0 rounded-lg border border-border/70 bg-background shadow-sm"
169 >
170 {glow && (
171 <>
172 <motion.div
173 aria-hidden
174 className="pointer-events-none absolute inset-0 rounded-lg"
175 style={{
176 boxShadow: `0 0 ${glowStyle.haloBlur}px ${glowStyle.haloBlur / 2}px ${glowColor}`,
177 }}
178 animate={
179 reduceMotion
180 ? { opacity: glowStyle.haloOpacity }
181 : {
182 opacity: [
183 glowStyle.haloOpacity * 0.75,
184 glowStyle.haloOpacity,
185 glowStyle.haloOpacity * 0.75,
186 ],
187 }
188 }
189 transition={{
190 duration: 1.8,
191 ease: "easeInOut",
192 repeat: reduceMotion ? 0 : Infinity,
193 }}
194 />
195 <motion.div
196 aria-hidden
197 className="pointer-events-none absolute inset-0 rounded-lg"
198 style={{
199 boxShadow: `inset 0 0 ${glowStyle.ringBlur}px ${glowColor}`,
200 }}
201 animate={{ opacity: glowStyle.ringOpacity }}
202 transition={{ duration: reduceMotion ? 0 : 0.2 }}
203 />
204 </>
205 )}
206 </motion.div>
207
208 {tabs.map((tab) => {
209 const isActive = tab === activeTab;
210 return (
211 <TabsTrigger
212 key={tab}
213 value={tab}
214 className={cn(
215 "relative z-10 border-0 bg-transparent shadow-none ring-0 after:hidden focus-visible:ring-2 focus-visible:ring-ring/60",
216 morphTabsTriggerVariants({ size, active: isActive }),
217 "data-[state=active]:border-0 data-[state=active]:!bg-transparent data-[state=active]:!shadow-none dark:data-[state=active]:!bg-transparent dark:data-[state=active]:!border-transparent",
218 )}
219 >
220 <span>{tab}</span>
221 </TabsTrigger>
222 );
223 })}
224 </TabsList>
225
226 {panels &&
227 tabs.map((tab) => (
228 <TabsContent
229 key={tab}
230 value={tab}
231 className="mt-3 data-[state=inactive]:hidden"
232 >
233 {tab === activeTab && (
234 <motion.div
235 key={activeTab}
236 initial={{ opacity: 0, y: 6 }}
237 animate={{ opacity: 1, y: 0 }}
238 transition={{ duration: 0.8, ease: "easeInOut" }}
239 className={cn(
240 "rounded-xl border border-border/70 bg-card p-4 text-sm",
241 panelClassName,
242 )}
243 >
244 {panels[tab]}
245 </motion.div>
246 )}
247 </TabsContent>
248 ))}
249 </Tabs>
250 );
251}
252
253export { morphTabsVariants, morphTabsTriggerVariants };
Strong glow
Stronger glow with a custom accent color

Engineering progress

Sprint velocity: 92 points, 3 blockers under review.

1import { MorphTabs } from "@/registry/ui/morph-tabs"
2
3<div className="mx-auto w-full max-w-2xl">
4 <MorphTabs
5 tabs={["Design", "Build", "Ship"]}
6 defaultActive="Build"
7 glow
8 glowIntensity="strong"
9 glowColor="rgb(129 50 50)"
10 panels={{
11 Design: <p className="text-sm text-muted-foreground">Specs, wireframes, and approval notes.</p>,
12 Build: <p className="text-sm text-muted-foreground">Current sprint velocity: 92 points.</p>,
13 Ship: <p className="text-sm text-muted-foreground">Release train status: On schedule.</p>,
14 }}
15 />
16</div>

Props

NameTypeDefaultDescription
tabsstring[]requiredOrdered list of tab labels
active / onActiveChangestring / (value: string) => voiduncontrolled if omittedControlled mode support for selected tab state
defaultActivestringfirst tabInitial active tab in uncontrolled mode
panels / panelClassNameRecord<string, ReactNode> / stringundefinedOptional tab-specific content panel rendered under tabs
glowbooleantrueToggles decorative glow layers on the active pill
glowIntensity"subtle" | "default" | "strong""default"Controls glow spread and opacity when glow is enabled
glowColorstring"hsl(var(--primary))"CSS color value used for active pill glow layers
size (CVA)"sm" | "default" | "lg""default"Adjusts wrapper and trigger sizing