Circle Menu
Dialog Form
Dominoes List Scroll
Dominoes Scroll Indicator
Eagle Vision
Electric AI Input
File Input
Flip Scroll
Glowing Scroll Indicator
Horizontal Scroll
Icon Wheel
Image Pile
Interactive CTA
Interactive Folder
Interest Picker
Jelly Loader
Leave Rating
Mask Cursor Effect
Magnet Tabs
Masonry Grid
OTP Input
Photo Gallery
Pixelated Carousel
Rolling Ball Scroll Indicator
Rubik Cube
Sidebar
Sine Wave
Skeumorphic Music Card
Social Media Card
Stacked Input Form
Stack Scroll
Trading Card
Interest Picker
An interactive component that allows users to select their interests from a visually appealing marquee-style grid. Users can choose from predefined categories with smooth animations and a minimum selection requirement.
Install dependencies
npm i framer-motion lucide-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));
}Component
Create a file interest-picker.tsx in your components folder and paste this code
import React, { useEffect, useState } from 'react';
import {
Gamepad2,
Music,
BookOpen,
GraduationCap,
Camera,
Palette,
Code,
Dumbbell,
Plane,
Heart,
Coffee,
Car,
Home,
Utensils,
Shirt,
TreePine,
Star,
Users,
Globe,
Zap,
LucideIcon,
ArrowRight,
LoaderCircle,
Brush,
Guitar,
Mountain,
Waves,
Microscope,
Coins,
PenTool,
Headphones,
Flower,
Rocket,
Brain,
Compass,
Trophy,
Briefcase,
Sparkles
} from 'lucide-react';
import { useAnimationControls, motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
export interface Interest {
id: string;
name: string;
icon: LucideIcon;
}
export const DUMMY_INTERESTS: Interest[] = [
{ id: '23', name: 'Hiking & Climbing', icon: Mountain },
{ id: '7', name: 'Programming', icon: Code },
{ id: '31', name: 'Psychology & Mind', icon: Brain },
{ id: '12', name: 'Fashion', icon: Shirt },
{ id: '28', name: 'Podcasts & Audio', icon: Headphones },
{ id: '5', name: 'Photography', icon: Camera },
{ id: '17', name: 'Sports', icon: Zap },
{ id: '35', name: 'Magic & Illusion', icon: Sparkles },
{ id: '2', name: 'Music', icon: Music },
{ id: '26', name: 'Finance & Investing', icon: Coins },
{ id: '14', name: 'Movies & TV', icon: Star },
{ id: '9', name: 'Travel', icon: Plane },
{ id: '21', name: 'Painting', icon: Brush },
{ id: '34', name: 'Entrepreneurship', icon: Briefcase },
{ id: '8', name: 'Fitness', icon: Dumbbell },
{ id: '18', name: 'Coffee Culture', icon: Coffee },
{ id: '30', name: 'Space & Astronomy', icon: Rocket },
{ id: '4', name: 'Learning', icon: GraduationCap },
{ id: '11', name: 'Food & Cooking', icon: Utensils },
{ id: '24', name: 'Water Sports', icon: Waves },
{ id: '16', name: 'Technology', icon: Globe },
{ id: '33', name: 'Competitive Gaming', icon: Trophy },
{ id: '1', name: 'Gaming', icon: Gamepad2 },
{ id: '29', name: 'Gardening & Plants', icon: Flower },
{ id: '6', name: 'Art & Design', icon: Palette },
{ id: '19', name: 'Automotive', icon: Car },
{ id: '27', name: 'Writing & Blogging', icon: PenTool },
{ id: '13', name: 'Nature', icon: TreePine },
{ id: '22', name: 'Musical Instruments', icon: Guitar },
{ id: '10', name: 'Health & Wellness', icon: Heart },
{ id: '32', name: 'Adventure & Exploring', icon: Compass },
{ id: '15', name: 'Socializing', icon: Users },
{ id: '25', name: 'Science & Research', icon: Microscope },
{ id: '3', name: 'Reading', icon: BookOpen },
{ id: '20', name: 'Home & Garden', icon: Home }
];
const MarqueeItem = ({
interest,
row,
isSelected = false,
onSelect,
onRemove,
showEmptySlot
}: {
interest: Interest;
row: number;
isSelected?: boolean;
onSelect?: (interest: Interest) => Promise<void>;
onRemove?: (interest: Interest) => Promise<void>;
showEmptySlot?: boolean;
}) => {
const animate = useAnimationControls();
const [isAnimating, setIsAnimating] = useState(false);
const [firstTime, setFirstTime] = useState(true);
useEffect(() => {
if (isSelected) return;
if (firstTime) {
setFirstTime(false);
return;
}
animate.start({
y: 0,
zIndex: 0,
rotateZ: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 100,
damping: 20
}
});
}, [isSelected]);
const handleClick = async () => {
if (onSelect) {
setIsAnimating(true);
try {
await Promise.all([
animate.start({
y: 32 * 7 - row * 32,
zIndex: 1,
rotateZ: Math.random() > 0.5 ? 45 : -45,
opacity: 0,
transition: {
type: 'spring',
stiffness: 100,
damping: 20
}
}),
onSelect(interest)
]);
} catch (error) {
console.error(error);
}
setIsAnimating(false);
} else if (onRemove) {
try {
// reverse the animation
await onRemove(interest);
} catch (error) {
console.error(error);
}
}
};
return (
<motion.div
layout
className={cn('rounded-full active:scale-[0.97]', showEmptySlot && 'bg-white/5')}
>
<motion.button
layout
initial={{
opacity: 0
}}
whileInView={{
opacity: 1
}}
animate={animate}
onClick={handleClick}
className={cn(
'w-fit bg-zinc-900 border border-white/10 hover:border-white/20 flex items-center justify-start gap-2 shrink-0 h-8 px-3 rounded-full select-none cursor-pointer',
isSelected && 'pointer-events-none',
isSelected && !isAnimating && '!opacity-0'
)}
>
<interest.icon className="w-4 h-4" />
<p className="text-sm whitespace-nowrap">{interest.name}</p>
</motion.button>
</motion.div>
);
};
const InterestMarquee = ({
selectedInterests,
onSelect,
interests,
showEmptySlot
}: {
selectedInterests: Interest[];
onSelect: (interest: Interest) => Promise<void>;
interests: Interest[];
showEmptySlot?: boolean;
}) => {
return (
<div className="relative mt-4">
<div className="absolute w-full h-full inset-0 z-10 bg-gradient-to-l from-[#111111] pointer-events-none to-20% to-transparent" />
<div className="absolute w-full h-full inset-0 z-10 bg-gradient-to-t from-[#111111] pointer-events-none to-20% to-transparent" />
<div className="grid grid-rows-5 gap-2 overflow-x-auto overflow-y-hidden relative pr-20 pb-10">
{Array.from({ length: 5 }).map((_, index) => {
return (
<div key={index} className="w-full flex items-center justify-start gap-2">
{interests
.slice(index * (interests.length / 5), (index + 1) * (interests.length / 5))
.map((interest) => {
return (
<MarqueeItem
key={interest.id}
interest={interest}
row={index}
isSelected={selectedInterests.some((i) => i.id === interest.id)}
onSelect={onSelect}
showEmptySlot={showEmptySlot}
/>
);
})}
</div>
);
})}
</div>
</div>
);
};
const SelectedInterests = ({
selectedInterests,
onRemove
}: {
selectedInterests: Interest[];
onRemove: (interest: Interest) => Promise<void>;
}) => {
return (
<div className="flex max-w-[500px] w-full overflow-x-auto items-center justify-start gap-2 h-8">
{selectedInterests.map((interest) => (
<MarqueeItem key={interest.id} interest={interest} row={0} onRemove={onRemove} />
))}
</div>
);
};
const InterestPicker = ({
interests = DUMMY_INTERESTS, // You can pass your own interests category here
showEmptySlot = false, // You can choose to show an empty slot for the selected interests
onSubmit = async (interests: Interest[]) => {
// You can pass your own onSubmit function here
console.log('Selected interests:', interests);
await new Promise((resolve) => setTimeout(resolve, 4000));
}
}: {
interests?: Interest[];
showEmptySlot?: boolean;
onSubmit?: (interests: Interest[]) => Promise<void> | void;
}) => {
const [selectedInterests, setSelectedInterests] = useState<Interest[]>([]);
const [loading, setLoading] = useState(false);
const handleSelect = async (interest: Interest) => {
setSelectedInterests((prev) => [interest, ...prev]);
};
const handleRemove = async (interest: Interest) => {
setSelectedInterests((prev) => prev.filter((i) => i.id !== interest.id));
};
const handleSubmit = async () => {
if (onSubmit) {
setLoading(true);
await onSubmit(selectedInterests);
setLoading(false);
}
};
return (
<div className="flex flex-col max-w-[500px] w-full">
<div className="flex items-end justify-between">
<div className="flex flex-col">
<p className="text-base font-medium">Tell us about your interests</p>
<p className="text-sm font-muted-foreground text-white/70">Select atleast 5 interests</p>
</div>
<AnimatePresence mode="popLayout">
{selectedInterests.length >= 5 ? (
<motion.button
initial={{
x: -10,
opacity: 0
}}
animate={{
x: 0,
opacity: 1
}}
exit={{
x: -10,
opacity: 0
}}
key="button"
type="button"
className="w-9 h-9 flex items-center justify-center rounded-full bg-blue-500 cursor-pointer"
onClick={handleSubmit}
disabled={loading}
>
{loading ? (
<motion.div
initial={{
opacity: 0
}}
animate={{
opacity: 1
}}
exit={{
opacity: 0
}}
key="loader"
>
<LoaderCircle className="animate-spin" />{' '}
</motion.div>
) : (
<motion.div
initial={{
opacity: 0
}}
animate={{
opacity: 1
}}
exit={{
opacity: 0
}}
key="arrow"
>
<ArrowRight />
</motion.div>
)}
</motion.button>
) : (
<motion.p
initial={{
x: 10,
opacity: 0
}}
animate={{
x: 0,
opacity: 1
}}
exit={{
x: 10,
opacity: 0
}}
key="selected-interests-length"
className="text-sm font-muted-foreground text-white/70"
>
{selectedInterests.length}
</motion.p>
)}
</AnimatePresence>
</div>
<InterestMarquee
selectedInterests={selectedInterests}
onSelect={handleSelect}
interests={interests}
showEmptySlot={showEmptySlot}
/>
<SelectedInterests selectedInterests={selectedInterests} onRemove={handleRemove} />
</div>
);
};
export default InterestPicker;Usage
// Basic usage with default interests
<InterestPicker />
// Custom interests array
<InterestPicker
interests={[
{ id: '1', name: 'Custom Interest', icon: SomeIcon }
]}
/>
// Show empty slots for visual effect
<InterestPicker showEmptySlot={true} />
// Custom submit handler
<InterestPicker
onSubmit={async (selectedInterests) => {
console.log('Selected interests:', selectedInterests);
// Handle the selected interests
}}
/>