Display15
Form1
Playground3
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
morph-tabs.tsx
1"use client";23import * 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";89const 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);2425const 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);4546export 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}5859export 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 });8889 React.useEffect(() => {90 if (!controlled && activeTab !== internalActive) {91 setInternalActive(activeTab);92 }93 }, [controlled, activeTab, internalActive]);9495 const setActive = React.useCallback(96 (value: string) => {97 if (!controlled) {98 setInternalActive(value);99 }100 onActiveChange?.(value);101 },102 [controlled, onActiveChange],103 );104105 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;112113 setPillStyle({114 left: activeEl.offsetLeft,115 width: activeEl.offsetWidth,116 });117 }, []);118119 React.useEffect(() => {120 updatePillPosition();121 }, [updatePillPosition, tabs, size, activeTab]);122123 React.useEffect(() => {124 window.addEventListener("resize", updatePillPosition);125 return () => window.removeEventListener("resize", updatePillPosition);126 }, [updatePillPosition]);127128 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];149150 if (!tabs.length) {151 return null;152 }153154 return (155 <Tabs value={activeTab} onValueChange={setActive} className={className}>156 <TabsList157 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.div165 aria-hidden166 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.div173 aria-hidden174 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 reduceMotion180 ? { 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.div196 aria-hidden197 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>207208 {tabs.map((tab) => {209 const isActive = tab === activeTab;210 return (211 <TabsTrigger212 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>225226 {panels &&227 tabs.map((tab) => (228 <TabsContent229 key={tab}230 value={tab}231 className="mt-3 data-[state=inactive]:hidden"232 >233 {tab === activeTab && (234 <motion.div235 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}252253export { morphTabsVariants, morphTabsTriggerVariants };
Subtle glow
Subtle glow with default color
MRR
$42,380
Active users
12,481
Churn
1.8%
morph-tabs.tsx
1"use client";23import * 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";89const 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);2425const 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);4546export 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}5859export 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 });8889 React.useEffect(() => {90 if (!controlled && activeTab !== internalActive) {91 setInternalActive(activeTab);92 }93 }, [controlled, activeTab, internalActive]);9495 const setActive = React.useCallback(96 (value: string) => {97 if (!controlled) {98 setInternalActive(value);99 }100 onActiveChange?.(value);101 },102 [controlled, onActiveChange],103 );104105 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;112113 setPillStyle({114 left: activeEl.offsetLeft,115 width: activeEl.offsetWidth,116 });117 }, []);118119 React.useEffect(() => {120 updatePillPosition();121 }, [updatePillPosition, tabs, size, activeTab]);122123 React.useEffect(() => {124 window.addEventListener("resize", updatePillPosition);125 return () => window.removeEventListener("resize", updatePillPosition);126 }, [updatePillPosition]);127128 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];149150 if (!tabs.length) {151 return null;152 }153154 return (155 <Tabs value={activeTab} onValueChange={setActive} className={className}>156 <TabsList157 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.div165 aria-hidden166 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.div173 aria-hidden174 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 reduceMotion180 ? { 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.div196 aria-hidden197 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>207208 {tabs.map((tab) => {209 const isActive = tab === activeTab;210 return (211 <TabsTrigger212 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>225226 {panels &&227 tabs.map((tab) => (228 <TabsContent229 key={tab}230 value={tab}231 className="mt-3 data-[state=inactive]:hidden"232 >233 {tab === activeTab && (234 <motion.div235 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}252253export { morphTabsVariants, morphTabsTriggerVariants };
Strong glow
Stronger glow with a custom accent color
Engineering progress
Sprint velocity: 92 points, 3 blockers under review.
tsx
1import { MorphTabs } from "@/registry/ui/morph-tabs"23<div className="mx-auto w-full max-w-2xl">4 <MorphTabs5 tabs={["Design", "Build", "Ship"]}6 defaultActive="Build"7 glow8 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
| Name | Type | Default | Description |
|---|---|---|---|
| tabs | string[] | required | Ordered list of tab labels |
| active / onActiveChange | string / (value: string) => void | uncontrolled if omitted | Controlled mode support for selected tab state |
| defaultActive | string | first tab | Initial active tab in uncontrolled mode |
| panels / panelClassName | Record<string, ReactNode> / string | undefined | Optional tab-specific content panel rendered under tabs |
| glow | boolean | true | Toggles decorative glow layers on the active pill |
| glowIntensity | "subtle" | "default" | "strong" | "default" | Controls glow spread and opacity when glow is enabled |
| glowColor | string | "hsl(var(--primary))" | CSS color value used for active pill glow layers |
| size (CVA) | "sm" | "default" | "lg" | "default" | Adjusts wrapper and trigger sizing |