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
}}
/>
Tell us about your interests
Select atleast 5 interests
0