"use client"
import React from "react"
import { AnimatePresence, HTMLMotionProps, motion } from "motion/react"
import { cn } from "@/lib/utils"
export interface ExpandableButtonProps {
/**
* Controls whether the button is in its expanded state.
* When true, shows both icon and text. When false, shows only the icon.
*/
expanded?: boolean
/**
* Callback function called when the expanded state changes.
* @param open - The new expanded state
*/
onExpandedChange?: (open: boolean) => void
/**
* The icon to display in the button.
* Shows in both collapsed and expanded states.
*/
icon?: React.ReactNode
}
export function ExpandableButton({
expanded: expandedProp,
onExpandedChange: setExpandedProp,
icon,
className,
onClick,
children,
...props
}: HTMLMotionProps<"button"> & ExpandableButtonProps) {
const [_expanded, _setExpanded] = React.useState(false)
const expanded = expandedProp ?? _expanded
const setExpanded = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const expandedState =
typeof value === "function" ? value(expanded) : value
if (setExpandedProp) {
setExpandedProp(expandedState)
} else {
_setExpanded(expandedState)
}
},
[setExpandedProp, expanded],
)
const onClickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
setExpanded(!expanded)
onClick?.(e)
}
return (
<motion.button
layout
onClick={onClickHandler}
className={cn(
"text-primary-foreground bg-primary relative flex h-10 max-w-full min-w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl text-lg font-medium",
className,
)}
initial={false}
animate={{
flexGrow: expanded ? 1 : 0,
maxWidth: expanded ? "100%" : "3rem",
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
{...props}
>
<AnimatePresence mode="wait">
{expanded ? (
<motion.div
key="active"
className={cn("flex h-full w-full items-center justify-center")}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="flex w-full items-center justify-center gap-2">
<motion.div
initial={{ scale: 1.2 }}
animate={{ scale: 1 }}
transition={{ duration: 0.2 }}
>
{icon}
</motion.div>
<motion.span
className="whitespace-nowrap"
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
>
{children}
</motion.span>
</div>
</motion.div>
) : (
<motion.div
key="inactive"
className={cn("flex items-center justify-center")}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
>
{icon}
</motion.div>
)}
</AnimatePresence>
</motion.button>
)
}
Expandable Button Group Demo
Try the interaction, copy the React source files, or download the complete example.
About Expandable Button Group Demo
Expandable Button Group Demo is an animated React and Tailwind CSS button by Rushil Dhinoja. Try the interaction, copy the React source, or download the example with creator credit.
- Effects and categories
- Hover effects, Animated
- Using this example
- Copy a TSX file or download the original source and standalone demo. Test keyboard focus, motion and contrast in your own project.
Usage, Tailwind and licensing questions
Read the source files
registry/example/expandable-button-group-demo.tsx
"use client"
import { useState } from "react"
import { Inbox, Mail, Settings, Star, User } from "lucide-react"
import { cn } from "@/lib/utils"
import { ExpandableButton } from "@/registry/molecule-ui/expandable-button"
export function ExpandableGroupButtonDemo() {
const [activeId, setActiveId] = useState(0)
return (
<div className="flex w-full items-center justify-center gap-2">
<ExpandableButton
className={cn(activeId === 1 && "bg-red-400 text-white")}
onClick={() => setActiveId(1)}
expanded={activeId === 1}
icon={<Inbox className="h-5 w-5" />}
>
Inbox
</ExpandableButton>
<ExpandableButton
className={cn(activeId === 2 && "bg-yellow-400 text-white")}
onClick={() => setActiveId(2)}
expanded={activeId === 2}
icon={<Mail className="h-5 w-5" />}
>
Mail
</ExpandableButton>
<ExpandableButton
className={cn(activeId === 3 && "bg-blue-400 text-white")}
onClick={() => setActiveId(3)}
expanded={activeId === 3}
icon={<Star className="h-5 w-5" />}
>
Favorites
</ExpandableButton>
<ExpandableButton
className={cn(activeId === 4 && "bg-orange-400 text-white")}
onClick={() => setActiveId(4)}
expanded={activeId === 4}
icon={<Settings className="h-5 w-5" />}
>
Settings
</ExpandableButton>
<ExpandableButton
className={cn(activeId === 5 && "bg-violet-400 text-white")}
onClick={() => setActiveId(5)}
expanded={activeId === 5}
icon={<User className="h-5 w-5" />}
>
Profile
</ExpandableButton>
</div>
)
}
lib/utils.ts
import { Metadata } from "next"
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
const DEFAULT_SITE_URL = "https://moleculeui.design"
function getBaseUrl() {
const base = process.env.NEXT_PUBLIC_APP_URL?.trim()
if (!base) {
return DEFAULT_SITE_URL
}
return base.endsWith("/") ? base.slice(0, -1) : base
}
export function absoluteUrl(path: string) {
return `${getBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`
}
export function constructMetadata({
title = "Molecule UI - Build Beautiful Interfaces",
description = "A modern React component library focused on intuitive interactions and seamless user experiences.",
image = absoluteUrl("/og"),
...props
}: {
title?: string
description?: string
image?: string
[key: string]: Metadata[keyof Metadata]
}): Metadata {
return {
title,
description,
keywords: [
"React",
"Tailwind CSS",
"Motion",
"Landing Page",
"Components",
"Next.js",
],
openGraph: {
title,
description,
type: "website",
images: [
{
url: image,
width: 1200,
height: 630,
},
],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [image],
creator: "@rushildhinoja17",
},
icons: "/favicon.ico",
metadataBase: new URL(getBaseUrl()),
authors: [
{
name: "rushil dhinoja",
url: "https://twitter.com/rushildhinoja17",
},
],
creator: "rushil dhinoja",
...props,
}
}
registry/molecule-ui/expandable-button.tsx
"use client"
import React from "react"
import { AnimatePresence, HTMLMotionProps, motion } from "motion/react"
import { cn } from "@/lib/utils"
export interface ExpandableButtonProps {
/**
* Controls whether the button is in its expanded state.
* When true, shows both icon and text. When false, shows only the icon.
*/
expanded?: boolean
/**
* Callback function called when the expanded state changes.
* @param open - The new expanded state
*/
onExpandedChange?: (open: boolean) => void
/**
* The icon to display in the button.
* Shows in both collapsed and expanded states.
*/
icon?: React.ReactNode
}
export function ExpandableButton({
expanded: expandedProp,
onExpandedChange: setExpandedProp,
icon,
className,
onClick,
children,
...props
}: HTMLMotionProps<"button"> & ExpandableButtonProps) {
const [_expanded, _setExpanded] = React.useState(false)
const expanded = expandedProp ?? _expanded
const setExpanded = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const expandedState =
typeof value === "function" ? value(expanded) : value
if (setExpandedProp) {
setExpandedProp(expandedState)
} else {
_setExpanded(expandedState)
}
},
[setExpandedProp, expanded],
)
const onClickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
setExpanded(!expanded)
onClick?.(e)
}
return (
<motion.button
layout
onClick={onClickHandler}
className={cn(
"text-primary-foreground bg-primary relative flex h-10 max-w-full min-w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl text-lg font-medium",
className,
)}
initial={false}
animate={{
flexGrow: expanded ? 1 : 0,
maxWidth: expanded ? "100%" : "3rem",
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
{...props}
>
<AnimatePresence mode="wait">
{expanded ? (
<motion.div
key="active"
className={cn("flex h-full w-full items-center justify-center")}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="flex w-full items-center justify-center gap-2">
<motion.div
initial={{ scale: 1.2 }}
animate={{ scale: 1 }}
transition={{ duration: 0.2 }}
>
{icon}
</motion.div>
<motion.span
className="whitespace-nowrap"
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
>
{children}
</motion.span>
</div>
</motion.div>
) : (
<motion.div
key="inactive"
className={cn("flex items-center justify-center")}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
>
{icon}
</motion.div>
)}
</AnimatePresence>
</motion.button>
)
}
demo.tsx
export { ExpandableGroupButtonDemo as default } from "@/registry/example/expandable-button-group-demo";CSS
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
"Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-red-400: oklch(70.4% 0.191 22.216);
--color-orange-400: oklch(75% 0.183 55.934);
--color-yellow-400: oklch(85.2% 0.199 91.936);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-violet-400: oklch(70.2% 0.183 293.541);
--color-white: #fff;
--spacing: 0.25rem;
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--font-weight-medium: 500;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.relative {
position: relative;
}
.flex {
display: flex;
}
.h-5 {
height: calc(var(--spacing) * 5);
}
.h-10 {
height: calc(var(--spacing) * 10);
}
.h-full {
height: 100%;
}
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-full {
width: 100%;
}
.max-w-full {
max-width: 100%;
}
.min-w-12 {
min-width: calc(var(--spacing) * 12);
}
.flex-shrink-0 {
flex-shrink: 0;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.overflow-hidden {
overflow: hidden;
}
.rounded-xl {
border-radius: calc(var(--radius) + 4px);
}
.bg-blue-400 {
background-color: var(--color-blue-400);
}
.bg-orange-400 {
background-color: var(--color-orange-400);
}
.bg-primary {
background-color: var(--primary);
}
.bg-red-400 {
background-color: var(--color-red-400);
}
.bg-violet-400 {
background-color: var(--color-violet-400);
}
.bg-yellow-400 {
background-color: var(--color-yellow-400);
}
.text-lg {
font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height));
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.whitespace-nowrap {
white-space: nowrap;
}
.text-primary-foreground {
color: var(--primary-foreground);
}
.text-white {
color: var(--color-white);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
}
:root {
--background: #fff;
--foreground: #111;
--primary: #171717;
--primary-foreground: #fff;
--secondary: #eee;
--secondary-foreground: #171717;
--muted: #eee;
--muted-foreground: #666;
--accent: #eee;
--accent-foreground: #171717;
--border: #ddd;
--input: #ddd;
--ring: #777;
--destructive: #dc2626;
--destructive-foreground: #fff;
--popover: #fff;
--popover-foreground: #111;
--success: #15803d;
--success-foreground: #fff;
--radius: .625rem;
--hairline: 1px;
}
.dark {
--background: #090909;
--foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a3a3a3;
--accent: #262626;
--accent-foreground: #fafafa;
--border: #333;
--input: #333;
--popover: #171717;
--popover-foreground: #fafafa;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-font-weight: initial;
}
}
}