Character Selector
A highly customizable character selection component with grid and dialog interfaces.
Waifu is laifu!!
Pick your Poison
Installation Guide
1
Install Dependencies
UI Components
npx shadcn@latest init
Framer Motion + Lucide React + Utility Functions
npm install framer-motion lucide-react clsx tailwind-merge
2
Setup Configuration
Create file:
/lib/utils.ts
/lib/utils.ts
Configuration1import { clsx, type ClassValue } from "clsx";
2 import { twMerge } from "tailwind-merge";
3
4 export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6 }
3
Copy Component Code
Character Selector.tsx
TypeScript1"use client";
2import React, {
3 useState,
4 useEffect,
5 useCallback,
6 useMemo,
7 CSSProperties,
8} from "react";
9import { motion, AnimatePresence } from "framer-motion";
10import { Search, Undo2, Plus, Check, X } from "lucide-react";
11import { cn } from "@/lib/utils";
12import { Button } from "@/components/ui/button";
13import {
14 Dialog,
15 DialogContent,
16 DialogHeader,
17 DialogTitle,
18 DialogFooter,
19 DialogDescription,
20} from "@/components/ui/dialog";
21import { Input } from "@/components/ui/input";
22import { ScrollArea } from "@/components/ui/scroll-area";
23import { Card, CardContent } from "@/components/ui/card";
24import Image from "next/image";
25
26export type Character = {
27 id: string;
28 name: string;
29 image: string;
30 category?: string;
31 demoImage?: string;
32};
33
34export type CharacterSelectorProps = {
35 cardsCount?: number;
36 cardImage?: string;
37 characterImages: Character[];
38 demoImage?: string;
39 gridColumns?: number;
40 multiSelect?: boolean;
41 enableSearch?: boolean;
42 lazyLoad?: boolean;
43 animationType?: "fade" | "slide" | "scale" | "none";
44 enableConfirmation?: boolean;
45 enableReset?: boolean;
46 customClass?: string;
47 cardHeight?: string | number;
48 cardWidth?: string | number;
49 dialogCardHeight?: string | number;
50 dialogCardWidth?: string | number;
51 cardBorderRadius?: string | number;
52 cardGap?: string | number;
53 displayMode?: "grid" | "flex";
54 cardAspectRatio?: string;
55 hoverEffect?: "scale" | "glow" | "lift" | "none";
56 selectedEffect?: "border" | "overlay" | "checkmark" | "multiple";
57 backgroundColor?: string;
58 textColor?: string;
59 imageObjectFit?: "cover" | "contain" | "fill" | "none";
60 shadowEffect?: "none" | "subtle" | "medium" | "strong";
61 selectionIndicatorPosition?:
62 | "topLeft"
63 | "topRight"
64 | "bottomLeft"
65 | "bottomRight";
66 selectionIndicatorSize?: string | number;
67 animationDuration?: number;
68 cardPadding?: string | number;
69 gridAutoFlow?: "row" | "column" | "dense";
70 gridAutoRows?: string;
71 gridAutoColumns?: string;
72 enableCardShadow?: boolean;
73 enableCardBorder?: boolean;
74 borderColor?: string;
75 nameVisibility?: "always" | "hover" | "selected" | "never";
76 emptyCardStyle?: "minimal" | "dashed" | "highlighted" | "custom";
77 cancelButtonText?: string;
78 confirmButtonText?: string;
79 resetButtonText?: string;
80 dialogTitle?: string;
81 noResultsText?: string;
82 clearFiltersText?: string;
83 searchPlaceholder?: string;
84 showDialogHeader?: boolean;
85 showDialogFooter?: boolean;
86 showSelectionCount?: boolean;
87 maxDialogHeight?: string | number;
88 dialogWidth?: string | number;
89 onSelectionChange?: (selectedCharacters: Character[]) => void;
90 onConfirm?: (selectedCharacters: Character[]) => void;
91};
92
93export function CharacterSelector({
94 cardsCount = 3,
95 cardImage,
96 characterImages = [],
97 gridColumns = 4,
98 multiSelect = true,
99 enableSearch = true,
100 lazyLoad = true,
101 animationType = "fade",
102 enableConfirmation = true,
103 enableReset = true,
104 customClass = "",
105 onSelectionChange,
106 onConfirm,
107 cardHeight = "auto",
108 cardWidth = "auto",
109 dialogCardHeight = "auto",
110 dialogCardWidth = "auto",
111 cardBorderRadius = 16,
112 cardGap = 16,
113 displayMode = "grid",
114 cardAspectRatio = "1/1",
115 hoverEffect = "scale",
116 selectedEffect = "multiple",
117 backgroundColor = "",
118 textColor = "",
119 imageObjectFit = "cover",
120 shadowEffect = "medium",
121 selectionIndicatorPosition = "topRight",
122 selectionIndicatorSize = 24,
123 animationDuration = 200,
124 cardPadding = 0,
125 gridAutoFlow = "row",
126 gridAutoRows = "auto",
127 gridAutoColumns = "auto",
128 enableCardShadow = true,
129 enableCardBorder = true,
130 borderColor = "",
131 nameVisibility = "hover",
132 emptyCardStyle = "minimal",
133 cancelButtonText = "Cancel",
134 confirmButtonText = "Confirm",
135 resetButtonText = "Reset",
136 dialogTitle = "Select Characters",
137 noResultsText = "No characters found",
138 clearFiltersText = "Clear filters",
139 searchPlaceholder = "Search characters...",
140 showDialogHeader = true,
141 showDialogFooter = true,
142 showSelectionCount = true,
143 maxDialogHeight = "90vh",
144 dialogWidth = "800px",
145}: CharacterSelectorProps) {
146 const [isOpen, setIsOpen] = useState(false);
147 const [selectedCharacters, setSelectedCharacters] = useState<Character[]>([]);
148 const [searchQuery, setSearchQuery] = useState("");
149 const [showConfirmation, setShowConfirmation] = useState(false);
150 const visibleItems = 12;
151
152 const filteredCharacters = useMemo(() => {
153 return characterImages.filter((character) => {
154 return character.name.toLowerCase().includes(searchQuery.toLowerCase());
155 });
156 }, [characterImages, searchQuery]);
157
158 const displayedCharacters = useMemo(() => {
159 return lazyLoad
160 ? filteredCharacters.slice(0, visibleItems)
161 : filteredCharacters;
162 }, [filteredCharacters, lazyLoad, visibleItems]);
163
164 const handleSelectCharacter = useCallback(
165 (character: Character) => {
166 setSelectedCharacters((prev) => {
167 const isSelected = prev.some((c) => c.id === character.id);
168
169 if (isSelected) {
170 return prev.filter((c) => c.id !== character.id);
171 } else {
172 if (multiSelect) {
173 return [...prev, character];
174 } else {
175 return [character];
176 }
177 }
178 });
179 },
180 [multiSelect]
181 );
182
183 const handleReset = useCallback(() => {
184 setSelectedCharacters([]);
185 }, []);
186
187 const handleConfirm = useCallback(() => {
188 if (enableConfirmation) {
189 setShowConfirmation(true);
190 } else {
191 onConfirm?.(selectedCharacters);
192 setIsOpen(false);
193 }
194 }, [enableConfirmation, selectedCharacters, onConfirm]);
195
196 const handleFinalConfirm = useCallback(() => {
197 onConfirm?.(selectedCharacters);
198 setShowConfirmation(false);
199 setIsOpen(false);
200 }, [selectedCharacters, onConfirm]);
201
202 useEffect(() => {
203 onSelectionChange?.(selectedCharacters);
204 }, [selectedCharacters, onSelectionChange]);
205
206 const animationVariants = {
207 fade: {
208 initial: { opacity: 0 },
209 animate: { opacity: 1 },
210 exit: { opacity: 0 },
211 },
212 slide: {
213 initial: { y: 20, opacity: 0 },
214 animate: { y: 0, opacity: 1 },
215 exit: { y: -20, opacity: 0 },
216 },
217 scale: {
218 initial: { scale: 0.9, opacity: 0 },
219 animate: { scale: 1, opacity: 1 },
220 exit: { scale: 0.9, opacity: 0 },
221 },
222 none: {
223 initial: {},
224 animate: {},
225 exit: {},
226 },
227 };
228
229 const getCardContainerStyle = (): CSSProperties => {
230 if (displayMode === "flex") {
231 return {
232 display: "flex",
233 alignItems: "center",
234 justifyContent: "center",
235 flexWrap: "wrap" as const,
236 gap: typeof cardGap === "number" ? `${cardGap}px` : cardGap,
237 };
238 } else {
239 return {
240 display: "grid",
241 gridTemplateColumns: `repeat(auto-fill, minmax(${
242 typeof cardWidth === "number" ? `${cardWidth}px` : cardWidth
243 }, 1fr))`,
244 gap: typeof cardGap === "number" ? `${cardGap}px` : cardGap,
245 gridAutoFlow: gridAutoFlow as "row" | "column" | "dense",
246 gridAutoRows,
247 gridAutoColumns,
248 };
249 }
250 };
251
252 const getShadowClass = () => {
253 if (!enableCardShadow) return "";
254
255 switch (shadowEffect) {
256 case "subtle":
257 return "shadow-sm";
258 case "medium":
259 return "shadow-md";
260 case "strong":
261 return "shadow-lg";
262 default:
263 return "";
264 }
265 };
266
267 const getHoverEffectClass = () => {
268 switch (hoverEffect) {
269 case "scale":
270 return "hover:scale-105";
271 case "glow":
272 return "hover:shadow-lg hover:shadow-primary/30";
273 case "lift":
274 return "hover:-translate-y-1";
275 default:
276 return "";
277 }
278 };
279
280 return (
281 <div className={cn("character-selector", customClass)}>
282 <div style={getCardContainerStyle()}>
283 {Array.from({ length: cardsCount }).map((_, index) => (
284 <CharacterCard
285 key={index}
286 character={selectedCharacters[index]}
287 onClick={() => setIsOpen(true)}
288 showPlus={!selectedCharacters[index]}
289 cardImage={cardImage}
290 cardHeight={cardHeight}
291 cardWidth={cardWidth}
292 cardBorderRadius={cardBorderRadius}
293 cardPadding={cardPadding}
294 cardAspectRatio={cardAspectRatio}
295 imageObjectFit={imageObjectFit}
296 shadowEffect={getShadowClass()}
297 hoverEffectClass={getHoverEffectClass()}
298 backgroundColor={backgroundColor}
299 enableCardBorder={enableCardBorder}
300 borderColor={borderColor}
301 emptyCardStyle={emptyCardStyle}
302 />
303 ))}
304 </div>
305
306 <Dialog open={isOpen} onOpenChange={setIsOpen}>
307 <DialogContent
308 className={`max-h-[${
309 typeof maxDialogHeight === "number"
310 ? `${maxDialogHeight}px`
311 : maxDialogHeight
312 }] flex flex-col bg-gradient-to-b from-background/50 dark:from-background/30 to-muted/30 backdrop-blur-sm border border-gray-500`}
313 style={{
314 maxWidth:
315 typeof dialogWidth === "number"
316 ? `${dialogWidth}px`
317 : dialogWidth,
318 }}
319 >
320 {showDialogHeader && (
321 <DialogHeader className="border-b border-muted/10">
322 <DialogTitle className="text-2xl font-bold tracking-tight">
323 {dialogTitle}
324 </DialogTitle>
325 </DialogHeader>
326 )}
327
328 <div className="flex flex-col gap-6 flex-1 overflow-hidden py-3">
329 {enableSearch && (
330 <div className="relative flex-1 mx-4">
331 <Search className="absolute left-3 top-1/2 h-5 w-5 transform -translate-y-1/2 opacity-80" />
332 <Input
333 placeholder={searchPlaceholder}
334 value={searchQuery}
335 onChange={(e) => setSearchQuery(e.target.value)}
336 className="w-full pl-10 pr-4 py-2 rounded-full bg-white/20 dark:bg-background/20 dark:text-white dark:placeholder-white transition duration-200"
337 />
338 </div>
339 )}
340
341 <ScrollArea className="h-[400px] p-2">
342 <AnimatePresence>
343 <motion.div
344 variants={animationVariants[animationType]}
345 initial="initial"
346 animate="animate"
347 exit="exit"
348 transition={{
349 duration: animationDuration / 1000,
350 staggerChildren: 0.05,
351 }}
352 >
353 <CharacterGrid
354 characters={displayedCharacters}
355 selectedCharacters={selectedCharacters}
356 onSelectCharacter={handleSelectCharacter}
357 gridColumns={gridColumns}
358 cardHeight={dialogCardHeight}
359 cardWidth={dialogCardWidth}
360 cardBorderRadius={cardBorderRadius}
361 imageObjectFit={imageObjectFit}
362 selectedEffect={selectedEffect}
363 nameVisibility={nameVisibility}
364 textColor={textColor}
365 selectionIndicatorPosition={selectionIndicatorPosition}
366 selectionIndicatorSize={selectionIndicatorSize}
367 hoverEffect={getHoverEffectClass()}
368 shadowEffect={getShadowClass()}
369 />
370
371 {filteredCharacters.length === 0 && (
372 <div className="flex flex-col items-center justify-center h-40 text-center">
373 <p className="text-muted-foreground">
374 🔍 {noResultsText}
375 </p>
376 <Button
377 variant="outline"
378 size="sm"
379 onClick={() => {
380 setSearchQuery("");
381 }}
382 className="mt-4 rounded-lg border-muted/30 hover:bg-muted/20"
383 >
384 {clearFiltersText}
385 </Button>
386 </div>
387 )}
388 </motion.div>
389 </AnimatePresence>
390 </ScrollArea>
391 </div>
392
393 {showDialogFooter && (
394 <DialogFooter className="flex justify-between sm:justify-between pt-4 border-t border-muted/10">
395 <div className="flex gap-2">
396 {enableReset && (
397 <Button
398 variant="outline"
399 size="sm"
400 onClick={handleReset}
401 disabled={selectedCharacters.length === 0}
402 className="rounded-lg border-muted/30 hover:bg-muted/20"
403 >
404 <Undo2 className="mr-2 h-4 w-4" />
405 {resetButtonText}
406 </Button>
407 )}
408 </div>
409 <div className="flex gap-3">
410 <Button
411 variant="outline"
412 onClick={() => setIsOpen(false)}
413 className="rounded-lg border-muted/30 hover:bg-muted/20"
414 >
415 {cancelButtonText}
416 </Button>
417 <Button
418 onClick={handleConfirm}
419 disabled={selectedCharacters.length === 0}
420 className="rounded-lg bg-primary hover:bg-primary/90"
421 >
422 {confirmButtonText}{" "}
423 {showSelectionCount &&
424 selectedCharacters.length > 0 &&
425 `(${selectedCharacters.length})`}
426 </Button>
427 </div>
428 </DialogFooter>
429 )}
430 </DialogContent>
431 </Dialog>
432
433 {enableConfirmation && (
434 <ConfirmationModal
435 open={showConfirmation}
436 onOpenChange={setShowConfirmation}
437 selectedCharacters={selectedCharacters}
438 onConfirm={handleFinalConfirm}
439 onCancel={() => setShowConfirmation(false)}
440 confirmButtonText={confirmButtonText}
441 cancelButtonText={cancelButtonText}
442 />
443 )}
444 </div>
445 );
446}
447
448interface CharacterCardProps {
449 character?: Character;
450 onClick: () => void;
451 showPlus?: boolean;
452 cardImage?: string;
453 cardHeight?: string | number;
454 cardWidth?: string | number;
455 cardBorderRadius?: string | number;
456 cardPadding?: string | number;
457 cardAspectRatio?: string;
458 imageObjectFit?: "cover" | "contain" | "fill" | "none";
459 shadowEffect?: string;
460 hoverEffectClass?: string;
461 backgroundColor?: string;
462 enableCardBorder?: boolean;
463 borderColor?: string;
464 emptyCardStyle?: "minimal" | "dashed" | "highlighted" | "custom";
465}
466
467function CharacterCard({
468 character,
469 onClick,
470 showPlus = true,
471 cardImage,
472 cardHeight = "auto",
473 cardWidth = "auto",
474 cardBorderRadius = 16,
475 cardPadding = 0,
476 cardAspectRatio = "1/1",
477 imageObjectFit = "cover",
478 shadowEffect = "",
479 hoverEffectClass = "",
480 backgroundColor = "",
481 enableCardBorder = true,
482 borderColor = "",
483 emptyCardStyle = "minimal",
484}: CharacterCardProps) {
485 const cardStyle: CSSProperties = {
486 height: typeof cardHeight === "number" ? `${cardHeight}px` : cardHeight,
487 width: typeof cardWidth === "number" ? `${cardWidth}px` : cardWidth,
488 borderRadius:
489 typeof cardBorderRadius === "number"
490 ? `${cardBorderRadius}px`
491 : cardBorderRadius,
492 padding: typeof cardPadding === "number" ? `${cardPadding}px` : cardPadding,
493 aspectRatio: cardAspectRatio,
494 backgroundColor: backgroundColor || undefined,
495 borderColor: borderColor || undefined,
496 borderStyle: enableCardBorder
497 ? emptyCardStyle === "dashed" && !character
498 ? "dashed"
499 : "solid"
500 : "none",
501 borderWidth: enableCardBorder ? "2px" : "0",
502 };
503
504 const getEmptyCardStyle = () => {
505 if (!character) {
506 switch (emptyCardStyle) {
507 case "dashed":
508 return "border-dashed border-2 border-muted/40";
509 case "highlighted":
510 return "bg-muted/70";
511 case "custom":
512 return backgroundColor
513 ? ""
514 : "bg-gradient-to-br from-muted/20 to-muted/10";
515 default:
516 return "bg-gradient-to-br from-muted/20 to-muted/5";
517 }
518 }
519 return "";
520 };
521
522 return (
523 <div className="card-wrapper relative">
524 <Card
525 className={cn(
526 "overflow-hidden cursor-pointer transition-all duration-300 rounded-none relative z-10",
527 shadowEffect,
528 hoverEffectClass,
529 "transition-all",
530 getEmptyCardStyle(),
531 "border-transparent"
532 )}
533 style={{ ...cardStyle, position: "relative", zIndex: 1 }}
534 onClick={onClick}
535 >
536 <CardContent className="p-0 relative flex items-center justify-center h-full overflow-hidden group">
537 {character ? (
538 <div className="w-full h-full border border-gray-500">
539 <div className="w-full h-full flex flex-col items-center justify-center">
540 <div className="w-full flex-grow">
541 <Image
542 src={character.image || "/placeholder.svg"}
543 alt={character.name}
544 width={500}
545 height={500}
546 loading="lazy"
547 quality={100}
548 className={cn("w-full h-full", `object-${imageObjectFit}`)}
549 />
550 </div>
551 <div className="w-full bg-black absolute bottom-0">
552 <div className="w-full h-1 relative overflow-hidden">
553 <div className="absolute inset-0 bg-gradient-to-r from-purple-600 via-pink-500 to-red-500" />
554 <div className="absolute inset-0 bg-gradient-to-r from-blue-500 via-green-500 to-yellow-500 animate-pulse" />
555 <div className="absolute inset-0 separator-shine" />
556 </div>
557 <h1 className="text-white font-medium text-sm text-center py-3">
558 {character.name}
559 </h1>
560 </div>
561 </div>
562 </div>
563 ) : (
564 <>
565 {cardImage ? (
566 <Image
567 src={cardImage || "/placeholder.svg"}
568 alt="Card background"
569 width={500}
570 height={500}
571 loading="lazy"
572 quality={100}
573 className={cn("w-full h-full", `object-${imageObjectFit}`)}
574 />
575 ) : (
576 <div className="w-full h-full flex items-center justify-center">
577 <Image
578 src="/placeholder.svg?height=80&width=80"
579 alt="Placeholder"
580 width={80}
581 height={80}
582 loading="lazy"
583 quality={100}
584 className="w-10 h-10 opacity-30"
585 />
586 </div>
587 )}
588 {showPlus && (
589 <div className="absolute inset-0 flex items-center border border-gray-500 justify-center bg-white dark:bg-black">
590 <motion.div
591 className="text-primary p-2 "
592 whileTap={{ scale: 0.95 }}
593 >
594 <Plus className="h-10 w-10" />
595 </motion.div>
596 </div>
597 )}
598 </>
599 )}
600 </CardContent>
601 </Card>
602 </div>
603 );
604}
605
606interface CharacterGridProps {
607 characters: Character[];
608 selectedCharacters: Character[];
609 onSelectCharacter: (character: Character) => void;
610 gridColumns?: number;
611 cardHeight?: string | number;
612 cardWidth?: string | number;
613 cardBorderRadius?: string | number;
614 imageObjectFit?: "cover" | "contain" | "fill" | "none";
615 selectedEffect?: "border" | "overlay" | "checkmark" | "multiple";
616 nameVisibility?: "always" | "hover" | "selected" | "never";
617 textColor?: string;
618 selectionIndicatorPosition?:
619 | "topLeft"
620 | "topRight"
621 | "bottomLeft"
622 | "bottomRight";
623 selectionIndicatorSize?: string | number;
624 hoverEffect?: string;
625 shadowEffect?: string;
626}
627
628function CharacterGrid({
629 characters,
630 selectedCharacters,
631 onSelectCharacter,
632 gridColumns = 4,
633 cardHeight = "auto",
634 cardWidth = "auto",
635 cardBorderRadius = 16,
636 imageObjectFit = "cover",
637 selectedEffect = "multiple",
638 nameVisibility = "hover",
639 textColor = "",
640 selectionIndicatorPosition = "topRight",
641 selectionIndicatorSize = 24,
642 hoverEffect = "",
643 shadowEffect = "",
644}: CharacterGridProps) {
645 const getIndicatorPosition = (position: string) => {
646 switch (position) {
647 case "topLeft":
648 return "top-2 left-2";
649 case "topRight":
650 return "top-2 right-2";
651 case "bottomLeft":
652 return "bottom-2 left-2";
653 case "bottomRight":
654 return "bottom-2 right-2";
655 default:
656 return "top-2 right-2";
657 }
658 };
659
660 const showName = (isSelected: boolean) => {
661 switch (nameVisibility) {
662 case "always":
663 return true;
664 case "hover":
665 return true;
666 case "selected":
667 return isSelected;
668 case "never":
669 return false;
670 default:
671 return true;
672 }
673 };
674
675 return (
676 <motion.div
677 className={cn(
678 "grid gap-6 p-[14px]",
679 gridColumns === 3 && "grid-cols-2 sm:grid-cols-3",
680 gridColumns === 4 && "grid-cols-2 sm:grid-cols-3 md:grid-cols-4",
681 gridColumns === 5 &&
682 "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
683 gridColumns === 6 &&
684 "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
685 )}
686 initial="hidden"
687 animate="visible"
688 variants={{
689 hidden: { opacity: 0 },
690 visible: {
691 opacity: 1,
692 transition: {
693 staggerChildren: 0.05,
694 delayChildren: 0.1,
695 },
696 },
697 }}
698 >
699 {characters.map((character) => {
700 const isSelected = selectedCharacters.some(
701 (c) => c.id === character.id
702 );
703 const cardStyle: CSSProperties = {
704 borderRadius:
705 typeof cardBorderRadius === "number"
706 ? `${cardBorderRadius}px`
707 : cardBorderRadius,
708 height:
709 typeof cardHeight === "number" ? `${cardHeight}px` : cardHeight,
710 width: typeof cardWidth === "number" ? `${cardWidth}px` : cardWidth,
711 };
712
713 return (
714 <motion.div
715 key={character.id}
716 className={cn(
717 "relative aspect-square overflow-hidden cursor-pointer group",
718 hoverEffect,
719 shadowEffect,
720 isSelected
721 ? "ring-2 ring-primary ring-offset-2 ring-offset-background"
722 : "",
723 "border-muted/20",
724 "rounded-xl border"
725 )}
726 style={cardStyle}
727 onClick={() => onSelectCharacter(character)}
728 role="button"
729 tabIndex={0}
730 aria-pressed={isSelected}
731 variants={{
732 hidden: {
733 opacity: 0,
734 y: 20,
735 scale: 0.9,
736 },
737 visible: {
738 opacity: 1,
739 y: 0,
740 scale: 1,
741 transition: {
742 type: "spring",
743 damping: 12,
744 stiffness: 200,
745 },
746 },
747 }}
748 whileHover={{ y: -5 }}
749 transition={{ type: "spring", stiffness: 400, damping: 17 }}
750 onKeyDown={(e) => {
751 if (e.key === "Enter" || e.key === " ") {
752 e.preventDefault();
753 onSelectCharacter(character);
754 }
755 }}
756 >
757 <div className="flex flex-col items-center justify-center h-full relative group overflow-hidden border border-gray-200 dark:border-gray-800 shadow-md transform transition-all duration-300 hover:scale-105 rounded-sm">
758 <div className="w-full h-64 overflow-hidden">
759 <Image
760 src={character.demoImage || "/placeholder.svg"}
761 alt={character.name}
762 width={500}
763 height={500}
764 loading="lazy"
765 quality={100}
766 className={`w-full h-full bg-white dark:bg-black object-${imageObjectFit} transition-transform duration-300 group-hover:scale-105`}
767 />
768 </div>
769 <div className="w-full h-1 bg-gradient-to-r from-purple-500 via-pink-500 to-red-500 relative">
770 <div className="absolute inset-0 bg-gradient-to-r from-blue-500 via-green-500 to-yellow-500 animate-pulse" />
771 <div className="absolute inset-0 w-full h-full bg-gradient-to-r from-transparent via-white to-transparent animate-shine" />
772 </div>
773 <div className="w-full py-1 px-4 bg-gray-900 dark:bg-black">
774 <span className="text-white font-medium text-center block text-sm">
775 {character.name}
776 </span>
777 </div>
778 </div>
779 {showName(isSelected) && (
780 <div
781 className={cn(
782 "absolute inset-0 flex items-center justify-center bg-black/40",
783 nameVisibility === "hover" && !isSelected
784 ? "opacity-0 hover:opacity-100"
785 : "opacity-100",
786 "transition-opacity"
787 )}
788 style={{ color: textColor || undefined }}
789 />
790 )}
791 {isSelected &&
792 (selectedEffect === "checkmark" ||
793 selectedEffect === "multiple") && (
794 <div
795 className={cn(
796 "absolute bg-primary text-primary-foreground rounded-full flex items-center justify-center",
797 getIndicatorPosition(selectionIndicatorPosition)
798 )}
799 style={{
800 width:
801 typeof selectionIndicatorSize === "number"
802 ? `${selectionIndicatorSize}px`
803 : selectionIndicatorSize,
804 height:
805 typeof selectionIndicatorSize === "number"
806 ? `${selectionIndicatorSize}px`
807 : selectionIndicatorSize,
808 }}
809 >
810 <Check className="w-4 h-4" />
811 </div>
812 )}
813 </motion.div>
814 );
815 })}
816 </motion.div>
817 );
818}
819
820interface ConfirmationModalProps {
821 open: boolean;
822 onOpenChange: (open: boolean) => void;
823 selectedCharacters: Character[];
824 onConfirm: () => void;
825 onCancel: () => void;
826 confirmButtonText?: string;
827 cancelButtonText?: string;
828}
829
830function ConfirmationModal({
831 open,
832 onOpenChange,
833 selectedCharacters,
834 onConfirm,
835 onCancel,
836 confirmButtonText = "Confirm",
837 cancelButtonText = "Cancel",
838}: ConfirmationModalProps) {
839 return (
840 <Dialog open={open} onOpenChange={onOpenChange}>
841 <DialogContent className="sm:max-w-md rounded-xl border-0 shadow-lg">
842 <DialogHeader className="space-y-2 pb-4 border-b border-gray-100 dark:border-gray-800">
843 <DialogTitle className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
844 <div className="bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-md px-2 py-1">
845 {selectedCharacters.length}
846 </div>
847 Character Selection
848 </DialogTitle>
849 <DialogDescription className="text-gray-600 dark:text-gray-300">
850 You are about to select {selectedCharacters.length} character
851 {selectedCharacters.length !== 1 ? "s" : ""}. Are you sure you want
852 to proceed?
853 </DialogDescription>
854 </DialogHeader>
855
856 <div className="py-4">
857 <ScrollArea className="max-h-64 px-1">
858 <div className="grid grid-cols-3 md:grid-cols-4 gap-3">
859 {selectedCharacters.map((character) => (
860 <div
861 key={character.id}
862 className="flex flex-col items-center group"
863 >
864 <div className="relative h-16 w-16 rounded-lg overflow-hidden shadow-md ring-2 ring-blue-500/20 group-hover:ring-blue-500/50 transition-all duration-300">
865 <div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
866 <Image
867 src={character.image || "/placeholder.svg"}
868 alt={character.name}
869 width={64}
870 height={64}
871 loading="lazy"
872 quality={100}
873 className="h-full w-full object-cover"
874 />
875 </div>
876 <span className="text-xs font-medium mt-2 text-center truncate w-full text-gray-700 dark:text-gray-200">
877 {character.name}
878 </span>
879 </div>
880 ))}
881 </div>
882 </ScrollArea>
883 </div>
884
885 <DialogFooter className="flex justify-end space-x-2 pt-4 border-t border-gray-100 dark:border-gray-800">
886 <Button
887 variant="outline"
888 onClick={onCancel}
889 className="rounded-lg border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800 transition-colors duration-200"
890 >
891 <X className="mr-2 h-4 w-4" />
892 {cancelButtonText}
893 </Button>
894 <Button
895 onClick={onConfirm}
896 className="rounded-lg bg-gradient-to-r from-indigo-500 to-purple-500 hover:bg-blue-700 text-white transition-colors duration-200"
897 >
898 <Check className="mr-2 h-4 w-4" />
899 {confirmButtonText}
900 </Button>
901 </DialogFooter>
902 </DialogContent>
903 </Dialog>
904 );
905}
906
4
Final Steps
Update Import Paths
Make sure to update the import paths in the component code to match your project structure. For example, change @/components/ui/button
to match your UI components location.
Props
Name | Type | Default | Description |
---|---|---|---|
cardsCount | number | 3 | Number of character cards to display in the main view |
cardImage | string | undefined | Fallback image URL for empty card slots |
characterImages | Character[] | [] | Array of character objects with id, name, and image properties |
gridColumns | number | 4 | Number of columns in the selection grid |
multiSelect | boolean | true | Whether multiple characters can be selected simultaneously |
enableSearch | boolean | true | Whether to show a search input for filtering characters |
lazyLoad | boolean | true | Whether to load characters gradually for better performance |
animationType | "fade" | "slide" | "scale" | "none" | "fade" | Type of animation for character transitions |
enableConfirmation | boolean | true | Whether to show a confirmation dialog before finalizing selection |
enableReset | boolean | true | Whether to show a reset button to clear selections |
customClass | string | "" | Additional CSS class names for the component |
cardHeight | string | number | "auto" | Height of character cards in the main view |
cardWidth | string | number | "auto" | Width of character cards in the main view |
dialogCardHeight | string | number | "auto" | Height of character cards in the dialog grid |
dialogCardWidth | string | number | "auto" | Width of character cards in the dialog grid |
cardBorderRadius | string | number | 16 | Border radius of character cards |
cardGap | string | number | 16 | Gap between character cards |
displayMode | "grid" | "flex" | "grid" | Layout mode for character cards |
cardAspectRatio | string | "1/1" | Aspect ratio for character cards |
hoverEffect | "scale" | "glow" | "lift" | "none" | "scale" | Visual effect when hovering over a character card |
selectedEffect | "border" | "overlay" | "checkmark" | "multiple" | "multiple" | Visual effect for selected character cards |
backgroundColor | string | "" | Background color for character cards |
textColor | string | "" | Text color for character names |
imageObjectFit | "cover" | "contain" | "fill" | "none" | "cover" | CSS object-fit property for character images |
shadowEffect | "none" | "subtle" | "medium" | "strong" | "medium" | Shadow effect for character cards |
selectionIndicatorPosition | "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | "topRight" | Position of the selection indicator on selected cards |
selectionIndicatorSize | string | number | 24 | Size of the selection indicator |
animationDuration | number | 200 | Duration of animations in milliseconds |
cardPadding | string | number | 0 | Padding inside character cards |
gridAutoFlow | "row" | "column" | "dense" | "row" | CSS grid-auto-flow property for character grid |
gridAutoRows | string | "auto" | CSS grid-auto-rows property for character grid |
gridAutoColumns | string | "auto" | CSS grid-auto-columns property for character grid |
enableCardShadow | boolean | true | Whether to apply shadow effects to cards |
enableCardBorder | boolean | true | Whether to apply borders to cards |
borderColor | string | "" | Border color for character cards |
nameVisibility | "always" | "hover" | "selected" | "never" | "hover" | When to display character names |
emptyCardStyle | "minimal" | "dashed" | "highlighted" | "custom" | "minimal" | Style variant for empty card slots |
cancelButtonText | string | "Cancel" | Text for the cancel button |
confirmButtonText | string | "Confirm" | Text for the confirm button |
resetButtonText | string | "Reset" | Text for the reset button |
dialogTitle | string | "Select Characters" | Title text for the selection dialog |
noResultsText | string | "No characters found" | Text displayed when no characters match the search |
clearFiltersText | string | "Clear filters" | Text for the button to clear search filters |
searchPlaceholder | string | "Search characters..." | Placeholder text for the search input |
showDialogHeader | boolean | true | Whether to show the dialog header |
showDialogFooter | boolean | true | Whether to show the dialog footer |
showSelectionCount | boolean | true | Whether to show the count of selected characters |
maxDialogHeight | string | number | "90vh" | Maximum height of the selection dialog |
dialogWidth | string | number | "800px" | Width of the selection dialog |
onSelectionChange | (selectedCharacters: Character[]) => void | undefined | Callback function when selection changes |
onConfirm | (selectedCharacters: Character[]) => void | undefined | Callback function when selection is confirmed |