Last active
April 14, 2025 06:48
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AnimatePresence, motion } from "motion/react"; | |
import { useRef, useState } from "react"; | |
type TabValue = "free" | "monthly" | "yearly"; | |
export default function App() { | |
const [value, setValue] = useState<TabValue>("free"); | |
const code = ` | |
// Wrap the Tab component in a div to control its dimensions | |
<div className="w-[500px] h-14"> | |
<Tab | |
value={value} | |
onChange={setValue} | |
theme={{ | |
selectedBgColor: "black", | |
unselectedColor: "var(--color-gray-200)", // supports css variables | |
nestedSelectedBgColor: "#FFF", | |
nestedSelectedColor: "rgb(0, 0, 0)", | |
nestedColor: "hsl(0, 0%, 100%)" | |
}} | |
/> | |
</div>` | |
return ( | |
<div className="bg-gray-100 h-screen w-screen flex flex-col gap-4 items-center justify-center"> | |
<p className="text-xs text-gray-500 font-mono my-10"> | |
[ | |
<a href="https://x.com/KumailNanji/status/1904512842684145854" className="text-blue-500 underline"> | |
design | |
</a> | |
] | |
by: <a href="https://x.com/KumailNanji" className="text-blue-500 underline">@KumailNanji</a> | |
<br /> | |
<b>code by</b> <a href="https://x.com/ditorodev" className="text-blue-500 underline">@ditorodev</a> | |
<br /> | |
<i>Copy the code and paste it into your project [<a href="https://gist.github.com/ditorodev/275f90b4e170eaa8f7d572b1268c06cd" className="text-blue-500 underline">here</a>]</i> | |
</p> | |
<div className="w-[500px] h-14"> | |
<Tab value={value} onChange={setValue} /> | |
</div> | |
<div> | |
<p className="text-xs text-gray-500 font-mono my-10">themed</p> | |
<div className="w-[500px] h-14"> | |
<Tab value={value} onChange={setValue} theme={{ | |
selectedBgColor: "var(--color-blue-600)", | |
selectedColor: "var(--color-blue-50)", | |
unselectedColor: "var(--color-gray-500)", | |
nestedSelectedBgColor: "var(--color-blue-800)", | |
nestedSelectedColor: "var(--color-blue-50)", | |
nestedColor: "var(--color-blue-800)", | |
baseBgColor: "var(--color-gray-900)", | |
borderColor: "var(--color-gray-700)", | |
}} /> | |
</div> | |
</div> | |
<div className="flex flex-row gap-4"> | |
<div className="font-mono text-sm text-gray-600 bg-gray-200 p-1 rounded-lg my-10 w-[300px] h-fit"> | |
<p className="text-xs text-gray-500 font-mono p-1">state</p> | |
<pre className="bg-gray-100 p-4 rounded-md"> | |
{JSON.stringify({ | |
value, | |
}, null, 2)} | |
</pre> | |
</div> | |
<div className="font-mono text-sm text-gray-600 bg-gray-200 p-1 rounded-lg my-10 min-w-[300px]"> | |
<p className="text-xs text-gray-500 font-mono p-1">usage</p> | |
<pre className="bg-gray-100 p-4 rounded-md"> | |
{code} | |
</pre> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
const DEFAULT_THEME = { | |
selectedBgColor: "black", | |
unselectedColor: "var(--color-gray-200)", | |
nestedSelectedBgColor: "white", | |
nestedSelectedColor: "black", | |
nestedColor: "white", | |
selectedColor: "white", | |
baseBgColor: "var(--color-gray-50)", | |
borderColor: "var(--color-gray-200)", | |
}; | |
const Tab = (props: { | |
value: TabValue; | |
onChange: (value: TabValue) => void; | |
theme?: { | |
selectedBgColor: string; | |
unselectedColor: string; | |
nestedSelectedBgColor: string; | |
nestedSelectedColor: string; | |
nestedColor: string; | |
selectedColor: string; | |
baseBgColor: string; | |
borderColor: string; | |
}; | |
}) => { | |
const { value, onChange, theme = DEFAULT_THEME } = props; | |
const selectedTab = value === "free" ? "free" : "premium"; | |
const refMonthly = useRef<HTMLDivElement>(null); | |
const refYearly = useRef<HTMLDivElement>(null); | |
return ( | |
<div | |
className="relative flex flex-row justify-between w-full rounded-full h-full bg-[var(--base-bg-color)] shadow-lg border border-[var(--border-color)]" | |
style={{ | |
"--base-bg-color": theme.baseBgColor, | |
"--selected-bg-color": theme.selectedBgColor, | |
"--unselected-color": theme.unselectedColor, | |
"--nested-selected-bg-color": theme.nestedSelectedBgColor, | |
"--nested-selected-color": theme.nestedSelectedColor, | |
"--nested-color": theme.nestedColor, | |
"--selected-color": theme.selectedColor, | |
"--border-color": theme.borderColor, | |
} as React.CSSProperties} | |
> | |
<motion.div | |
className="w-1/2 bg-[var(--selected-bg-color)] absolute top-[2px] bottom-[2px] rounded-full" | |
initial={false} | |
animate={{ | |
right: selectedTab === "free" ? "auto" : 2, | |
left: selectedTab === "free" ? 2 : "auto", | |
}} | |
style={{ | |
zIndex: selectedTab === "free" ? 3 : 1, | |
}} | |
/> | |
<div | |
className="transition-colors relative text-lg w-1/2 text-[var(--unselected-color)] data-[selected]:text-white text-center leading-10 h-full flex items-center justify-center cursor-pointer" | |
data-selected={selectedTab === "free" ? true : undefined} | |
onClick={() => onChange("free")} | |
style={{ | |
zIndex: 3, | |
}} | |
> | |
Free | |
</div> | |
<div | |
className="relative p-[.5px] w-1/2 overflow-clip flex flex-col data-[selected]:text-[var(--selected-color)] items-center justify-center" | |
data-selected={selectedTab === "premium" ? true : undefined} | |
> | |
<AnimatePresence> | |
<> | |
<motion.div | |
style={{ | |
color: selectedTab === "premium" ? "var(--selected-color)" : undefined, | |
display: selectedTab === "premium" ? "none" : "flex", | |
zIndex: 2, | |
}} | |
animate={{ opacity: 1, y: selectedTab === "premium" ? 20 : 0, scale: 1, filter: "blur(0px)" }} | |
exit={{ opacity: 0, scale: 0, filter: "blur(4px)" }} | |
transition={{ duration: 0.15 }} | |
className="flex flex-col items-center cursor-pointer w-full text-[var(--unselected-color)]" | |
onClick={() => onChange("monthly")} | |
> | |
Premium | |
<motion.div | |
className="relative flex flex-row items-center justify-center text-xs" | |
> | |
<div className="relative w-fit text-[var(--parent-color)] data-[selected]:text-[var(--parent-selected-color)]" data-selected> | |
Monthly | |
</div> | |
<div className="size-[2px] bg-current rounded-full mx-2" /> | |
<div className="relative w-fit text-[var(--parent-color)] data-[selected]:text-[var(--parent-selected-color)]">Yearly</div> | |
</motion.div> | |
</motion.div> | |
{selectedTab === "premium" && ( | |
<motion.div | |
className="relative w-full h-[calc(100%-8px)]" | |
initial={{ opacity: 0, scale: 0 }} | |
animate={{ opacity: 1, scale: 1 }} | |
transition={{ duration: 0.3 }} | |
style={{ | |
zIndex: 1, | |
pointerEvents: selectedTab === "premium" ? "auto" : "none", | |
}} | |
> | |
<motion.div | |
id="tab-indicator" | |
className="bg-[var(--nested-selected-bg-color)] absolute left-0 w-1/2 h-full top-0 bottom-0 rounded-full" | |
animate={value === "yearly" ? { | |
left: refYearly.current?.offsetLeft, | |
width: refYearly.current?.offsetWidth, | |
} : undefined} | |
/> | |
<div className="relative p-1 text-center text-[var(--nested-selected-color)] w-full h-full min-w-0 flex flex-row items-center justify-between text-base"> | |
<div | |
className="transition-colors w-full text-[var(--nested-color)] data-[selected]:text-[var(--nested-selected-color)] cursor-pointer" | |
data-selected={value === "monthly" ? true : undefined} | |
onClick={() => onChange("monthly")} | |
ref={refMonthly} | |
> | |
Monthly | |
</div> | |
<div | |
className="transition-colors w-full text-[var(--nested-color)] data-[selected]:text-[var(--nested-selected-color)] cursor-pointer" | |
data-selected={value === "yearly" ? true : undefined} | |
onClick={() => onChange("yearly")} | |
ref={refYearly} | |
> | |
Yearly | |
</div> | |
</div> | |
</motion.div> | |
)} | |
</> | |
</AnimatePresence> | |
</div> | |
</div> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AnimatePresence, motion } from "motion/react"; | |
import { useRef, useState } from "react"; | |
type TabValue = "free" | "monthly" | "yearly"; | |
const DEFAULT_THEME = { | |
selectedBgColor: "black", | |
unselectedColor: "var(--color-gray-200)", | |
nestedSelectedBgColor: "white", | |
nestedSelectedColor: "black", | |
nestedColor: "white", | |
selectedColor: "white", | |
baseBgColor: "var(--color-gray-50)", | |
borderColor: "var(--color-gray-200)", | |
}; | |
const Tab = (props: { | |
value: TabValue; | |
onChange: (value: TabValue) => void; | |
theme?: { | |
selectedBgColor: string; | |
unselectedColor: string; | |
nestedSelectedBgColor: string; | |
nestedSelectedColor: string; | |
nestedColor: string; | |
selectedColor: string; | |
baseBgColor: string; | |
borderColor: string; | |
}; | |
}) => { | |
const { value, onChange, theme = DEFAULT_THEME } = props; | |
const selectedTab = value === "free" ? "free" : "premium"; | |
const refMonthly = useRef<HTMLDivElement>(null); | |
const refYearly = useRef<HTMLDivElement>(null); | |
return ( | |
<div | |
className="relative flex flex-row justify-between w-full rounded-full h-full bg-[var(--base-bg-color)] shadow-lg border border-[var(--border-color)]" | |
style={{ | |
"--base-bg-color": theme.baseBgColor, | |
"--selected-bg-color": theme.selectedBgColor, | |
"--unselected-color": theme.unselectedColor, | |
"--nested-selected-bg-color": theme.nestedSelectedBgColor, | |
"--nested-selected-color": theme.nestedSelectedColor, | |
"--nested-color": theme.nestedColor, | |
"--selected-color": theme.selectedColor, | |
"--border-color": theme.borderColor, | |
} as React.CSSProperties} | |
> | |
<motion.div | |
className="w-1/2 bg-[var(--selected-bg-color)] absolute top-[2px] bottom-[2px] rounded-full" | |
initial={false} | |
animate={{ | |
right: selectedTab === "free" ? "auto" : 2, | |
left: selectedTab === "free" ? 2 : "auto", | |
}} | |
style={{ | |
zIndex: selectedTab === "free" ? 3 : 1, | |
}} | |
/> | |
<div | |
className="transition-colors relative text-lg w-1/2 text-[var(--unselected-color)] data-[selected]:text-white text-center leading-10 h-full flex items-center justify-center cursor-pointer" | |
data-selected={selectedTab === "free" ? true : undefined} | |
onClick={() => onChange("free")} | |
style={{ | |
zIndex: 3, | |
}} | |
> | |
Free | |
</div> | |
<div | |
className="relative p-[.5px] w-1/2 overflow-clip flex flex-col data-[selected]:text-[var(--selected-color)] items-center justify-center" | |
data-selected={selectedTab === "premium" ? true : undefined} | |
> | |
<AnimatePresence> | |
<> | |
<motion.div | |
style={{ | |
color: selectedTab === "premium" ? "var(--selected-color)" : undefined, | |
display: selectedTab === "premium" ? "none" : "flex", | |
zIndex: 2, | |
}} | |
animate={{ opacity: 1, y: selectedTab === "premium" ? 20 : 0, scale: 1, filter: "blur(0px)" }} | |
exit={{ opacity: 0, scale: 0, filter: "blur(4px)" }} | |
transition={{ duration: 0.15 }} | |
className="flex flex-col items-center cursor-pointer w-full text-[var(--unselected-color)]" | |
onClick={() => onChange("monthly")} | |
> | |
Premium | |
<motion.div | |
className="relative flex flex-row items-center justify-center text-xs" | |
> | |
<div className="relative w-fit text-[var(--parent-color)] data-[selected]:text-[var(--parent-selected-color)]" data-selected> | |
Monthly | |
</div> | |
<div className="size-[2px] bg-current rounded-full mx-2" /> | |
<div className="relative w-fit text-[var(--parent-color)] data-[selected]:text-[var(--parent-selected-color)]">Yearly</div> | |
</motion.div> | |
</motion.div> | |
{selectedTab === "premium" && ( | |
<motion.div | |
className="relative w-full h-[calc(100%-8px)]" | |
initial={{ opacity: 0, scale: 0 }} | |
animate={{ opacity: 1, scale: 1 }} | |
transition={{ duration: 0.3 }} | |
style={{ | |
zIndex: 1, | |
pointerEvents: selectedTab === "premium" ? "auto" : "none", | |
}} | |
> | |
<motion.div | |
id="tab-indicator" | |
className="bg-[var(--nested-selected-bg-color)] absolute left-0 w-1/2 h-full top-0 bottom-0 rounded-full" | |
animate={value === "yearly" ? { | |
left: refYearly.current?.offsetLeft, | |
width: refYearly.current?.offsetWidth, | |
} : undefined} | |
/> | |
<div className="relative p-1 text-center text-[var(--nested-selected-color)] w-full h-full min-w-0 flex flex-row items-center justify-between text-base"> | |
<div | |
className="transition-colors w-full text-[var(--nested-color)] data-[selected]:text-[var(--nested-selected-color)] cursor-pointer" | |
data-selected={value === "monthly" ? true : undefined} | |
onClick={() => onChange("monthly")} | |
ref={refMonthly} | |
> | |
Monthly | |
</div> | |
<div | |
className="transition-colors w-full text-[var(--nested-color)] data-[selected]:text-[var(--nested-selected-color)] cursor-pointer" | |
data-selected={value === "yearly" ? true : undefined} | |
onClick={() => onChange("yearly")} | |
ref={refYearly} | |
> | |
Yearly | |
</div> | |
</div> | |
</motion.div> | |
)} | |
</> | |
</AnimatePresence> | |
</div> | |
</div> | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
see in action https://motion-explorations.vercel.app/