Interactive CTA
A floating call-to-action component that expands from a compact icon to reveal contact information and navigation links. Features smooth animations, customizable positioning, and dynamic content.
Install dependencies
npm i framer-motion lucide-react @tabler/icons-react
Utility function
Create a file lib/utils.ts and paste this code
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Navigation Button Component
First, create a file navigation-button.tsx in your components folder and paste this code
'use client';
import { ArrowUpRight, Eye } from 'lucide-react';
import Link from 'next/link';
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@/lib/utils';
interface ButtonProps {
text: string;
href: string;
target?: '_self' | '_blank' | '_parent' | '_top';
className?: string;
icon?: React.ReactNode;
}
const NavigationButton = ({
href,
text = 'Open',
icon = undefined,
target = '_blank',
className = ''
}: ButtonProps) => {
const [hovered, setHovered] = useState(false);
return (
<div className="flex items-start">
<Link href={href} target={target}>
<AnimatePresence mode="popLayout">
<button
className={cn(
'flex items-center gap-1 outline-none cursor-pointer text-zinc-400 hover:text-blue-400 font-semibold shadow-sm py-2 px-4 hover:brightness-125 active:brightness-105 transition-opacity duration-100 rounded-lg',
className
)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{!hovered && (
<motion.div
key={'eye-btn' + href}
initial={{
x: -10,
opacity: 0
}}
animate={{
x: 0,
opacity: 1
}}
exit={{
x: -10,
opacity: 0
}}
transition={{
ease: 'linear',
duration: 0.1
}}
>
{icon ? icon : <Eye size={14} />}
</motion.div>
)}
<motion.p
layout
transition={{ duration: 0.1, ease: 'linear' }}
className="text-xs sm:text-sm whitespace-nowrap"
>
{text}
</motion.p>
{hovered && (
<motion.div
key={'arrow-btn' + href}
initial={{
x: 10,
opacity: 0
}}
animate={{
x: 0,
opacity: 1
}}
exit={{
x: 10,
opacity: 0
}}
transition={{
ease: 'linear',
duration: 0.1
}}
>
<ArrowUpRight size={14} />
</motion.div>
)}
</button>
</AnimatePresence>
</Link>
</div>
);
};
export default NavigationButton;
Interactive CTA Component
Create a file interactive-cta.tsx in your components folder and paste this code
'use client';
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { IconBrandX, IconMail } from '@tabler/icons-react';
import { MessageCircle, X } from 'lucide-react';
import NavigationButton from './navigation-button';
import { cn } from '@/lib/utils';
export interface NavigationLink {
href: string;
text: string;
icon?: React.ReactNode;
target?: '_self' | '_blank' | '_parent' | '_top';
className?: string;
}
export interface InteractiveCTAProps {
heading?: string;
subheading?: string;
avatar?: React.ReactNode;
navigationLinks?: NavigationLink[];
initialOpen?: boolean;
className?: string;
openIcon?: React.ReactNode;
closeIcon?: React.ReactNode;
openWidth?: string;
closeWidth?: string;
openHeight?: string;
closeHeight?: string;
}
const DEFAULT_NAVIGATION_LINKS: NavigationLink[] = [
{
href: 'https://twitter.com/samitkapoorr',
text: 'DM me on X',
className: 'px-2 py-1 text-white hover:text-blue-500',
icon: <IconBrandX size={14} />
},
{
href: 'mailto:samitkapoor77@gmail.com',
text: 'Send me an email',
className: 'px-2 py-1 text-white hover:text-red-500',
icon: <IconMail size={14} />
}
];
const InteractiveCTA = ({
heading = 'Want something custom made?',
subheading = "Let's talk",
avatar,
navigationLinks = DEFAULT_NAVIGATION_LINKS,
initialOpen = true,
className,
openIcon,
closeIcon,
openWidth = '310px',
closeWidth = '64px',
openHeight = '155px',
closeHeight = '64px'
}: InteractiveCTAProps) => {
const [isOpen, setIsOpen] = useState(initialOpen);
return (
<div className={cn('fixed z-50 bottom-4 right-4 overflow-hidden', className)}>
<AnimatePresence mode="popLayout">
<motion.div
animate={{
width: isOpen ? openWidth : closeWidth,
height: isOpen ? openHeight : closeHeight,
borderColor: !isOpen ? '#3F3F46' : '#888888'
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 27
}}
className="flex-col border-[2px] rounded-xl border-white/5 bg-zinc-900 flex items-center justify-center"
>
{!isOpen && (
<motion.button
key="open-button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(true)}
className="flex items-center hover:bg-white/5 rounded-xl justify-center absolute bottom-0 right-0"
style={{ width: closeWidth, height: closeHeight }}
>
{openIcon || <MessageCircle size={20} />}
</motion.button>
)}
{isOpen && (
<div className="flex flex-col p-4 h-full w-full">
<div className="flex items-center justify-center gap-2">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.1,
ease: 'linear',
delay: 0.1
}}
key="image-container"
className="rounded-full h-[42px] w-[42px] bg-gradient-to-b from-yellow-400 to-purple-500 flex items-center justify-center overflow-hidden"
>
{avatar}
</motion.div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.1,
ease: 'linear',
delay: 0.1
}}
key="text-container"
className="text-sm truncate text-white/70 font-medium"
>
{heading}
<br />
<span className="text-white">{subheading}</span>
</motion.p>
</div>
<motion.div
initial={{ x: 10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
transition={{
duration: 0.1,
ease: 'linear',
delay: 0.1,
staggerChildren: 0.1
}}
key="navigation-buttons"
className="flex flex-col mt-4"
>
{navigationLinks.map((link, index) => (
<NavigationButton
key={`${link.href}-${index}`}
href={link.href}
text={link.text}
className={link.className}
icon={link.icon}
target={link.target}
/>
))}
</motion.div>
<div className="absolute bottom-4 right-4">
<motion.button
key="close-button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="flex items-center justify-center hover:bg-white/10 place-self-end h-[32px] w-[32px] rounded-md"
>
{closeIcon || <X size={20} />}
</motion.button>
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
);
};
export default InteractiveCTA;
Usage
// Basic usage
<InteractiveCTA />
// With custom content
<InteractiveCTA
heading="Need help?"
subheading="Get in touch!"
avatar={<User size={24} className="text-white" />}
position="top-left"
initialOpen={false}
/>
// With custom navigation links
<InteractiveCTA
navigationLinks={[
{
href: "https://github.com/yourusername",
text: "View GitHub",
icon: <IconBrandGithub size={14} />,
className: "px-2 py-1 text-white hover:text-green-500"
},
{
href: "/contact",
text: "Contact Form",
target: "_self",
className: "px-2 py-1 text-white hover:text-purple-500"
}
]}
/>
// With custom dimensions
<InteractiveCTA
openWidth="400px"
openHeight="200px"
closeWidth="80px"
closeHeight="80px"
/>
Look at the bottom right corner