Introducing Nuvyx UI v1.0.0

Character Selector

A highly customizable character selection component with grid and dialog interfaces.

Waifu is laifu!!

Pick your Poison

Placeholder
Placeholder
Placeholder

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
Configuration
1import { 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
TypeScript
1"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

NameTypeDefaultDescription
cardsCountnumber3Number of character cards to display in the main view
cardImagestringundefinedFallback image URL for empty card slots
characterImagesCharacter[][]Array of character objects with id, name, and image properties
gridColumnsnumber4Number of columns in the selection grid
multiSelectbooleantrueWhether multiple characters can be selected simultaneously
enableSearchbooleantrueWhether to show a search input for filtering characters
lazyLoadbooleantrueWhether to load characters gradually for better performance
animationType"fade" | "slide" | "scale" | "none""fade"Type of animation for character transitions
enableConfirmationbooleantrueWhether to show a confirmation dialog before finalizing selection
enableResetbooleantrueWhether to show a reset button to clear selections
customClassstring""Additional CSS class names for the component
cardHeightstring | number"auto"Height of character cards in the main view
cardWidthstring | number"auto"Width of character cards in the main view
dialogCardHeightstring | number"auto"Height of character cards in the dialog grid
dialogCardWidthstring | number"auto"Width of character cards in the dialog grid
cardBorderRadiusstring | number16Border radius of character cards
cardGapstring | number16Gap between character cards
displayMode"grid" | "flex""grid"Layout mode for character cards
cardAspectRatiostring"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
backgroundColorstring""Background color for character cards
textColorstring""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
selectionIndicatorSizestring | number24Size of the selection indicator
animationDurationnumber200Duration of animations in milliseconds
cardPaddingstring | number0Padding inside character cards
gridAutoFlow"row" | "column" | "dense""row"CSS grid-auto-flow property for character grid
gridAutoRowsstring"auto"CSS grid-auto-rows property for character grid
gridAutoColumnsstring"auto"CSS grid-auto-columns property for character grid
enableCardShadowbooleantrueWhether to apply shadow effects to cards
enableCardBorderbooleantrueWhether to apply borders to cards
borderColorstring""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
cancelButtonTextstring"Cancel"Text for the cancel button
confirmButtonTextstring"Confirm"Text for the confirm button
resetButtonTextstring"Reset"Text for the reset button
dialogTitlestring"Select Characters"Title text for the selection dialog
noResultsTextstring"No characters found"Text displayed when no characters match the search
clearFiltersTextstring"Clear filters"Text for the button to clear search filters
searchPlaceholderstring"Search characters..."Placeholder text for the search input
showDialogHeaderbooleantrueWhether to show the dialog header
showDialogFooterbooleantrueWhether to show the dialog footer
showSelectionCountbooleantrueWhether to show the count of selected characters
maxDialogHeightstring | number"90vh"Maximum height of the selection dialog
dialogWidthstring | number"800px"Width of the selection dialog
onSelectionChange(selectedCharacters: Character[]) => voidundefinedCallback function when selection changes
onConfirm(selectedCharacters: Character[]) => voidundefinedCallback function when selection is confirmed