team-selector

PreviousNext
Docs
kokonutuicomponent

Preview

Loading preview…
/components/kokonutui/team-selector.tsx
"use client";

/**
 * @author: @dorianbaffier
 * @description: Team Selector
 * @version: 1.0.0
 * @date: 2025-06-26
 * @license: MIT
 * @website: https://kokonutui.com
 * @github: https://github.com/kokonut-labs/kokonutui
 */

import { AnimatePresence, motion, type Variants } from "motion/react";
import Image from "next/image";
import { useState } from "react";

const MAX_TEAM_SIZE = 4;

type TeamMember = {
  id: string;
  avatarUrl: string;
  name: string;
};

const TEAM_MEMBERS: TeamMember[] = [
  {
    id: "member-1",
    avatarUrl:
      "https://bykuknqwpctcjrowysyf.supabase.co/storage/v1/object/public/assets/avatar-01.png",
    name: "Team Member 1",
  },
  {
    id: "member-2",
    avatarUrl:
      "https://bykuknqwpctcjrowysyf.supabase.co/storage/v1/object/public/assets/avatar-02.png",
    name: "Team Member 2",
  },
  {
    id: "member-3",
    avatarUrl:
      "https://bykuknqwpctcjrowysyf.supabase.co/storage/v1/object/public/assets/avatar-03.png",
    name: "Team Member 3",
  },
  {
    id: "member-4",
    avatarUrl:
      "https://bykuknqwpctcjrowysyf.supabase.co/storage/v1/object/public/assets/avatar-04.png",
    name: "Team Member 4",
  },
] as const;

const animations = {
  container: {
    initial: { opacity: 0, y: 20 },
    animate: { opacity: 1, y: 0 },
    exit: { opacity: 0, y: -20 },
  } satisfies Variants,
  avatar: {
    initial: { opacity: 0, scale: 0.8 },
    animate: {
      opacity: 1,
      scale: 1,
      transition: { duration: 0.3 },
    },
    exit: {
      opacity: 0,
      scale: 0.8,
      transition: { duration: 0.15 },
    },
  } satisfies Variants,
  vibration: {
    initial: { x: 0 },
    animate: {
      x: [-5, 5, -5, 5, 0] as const,
      transition: { duration: 0.3 },
    },
  } satisfies Variants,
  number: {
    initial: { scale: 0.8, opacity: 0 },
    animate: {
      scale: 1,
      opacity: 1,
      transition: {
        type: "spring",
        stiffness: 400,
        damping: 25,
      },
    },
  } satisfies Variants,
} as const;

interface TeamSelectorProps {
  defaultValue?: number;
  onChange?: (size: number) => void;
  className?: string;
}

export default function TeamSelector({
  defaultValue = 1,
  onChange,
  className = "",
}: TeamSelectorProps) {
  const [peopleCount, setPeopleCount] = useState(defaultValue);
  const [isVibrating, setIsVibrating] = useState(false);

  const handleIncrement = (e: React.MouseEvent | React.KeyboardEvent) => {
    e.preventDefault();
    if (peopleCount < MAX_TEAM_SIZE) {
      const newCount = peopleCount + 1;
      setPeopleCount(newCount);
      onChange?.(newCount);
    } else {
      triggerVibration();
    }
  };

  const handleDecrement = (e: React.MouseEvent | React.KeyboardEvent) => {
    e.preventDefault();
    if (peopleCount > 1) {
      const newCount = peopleCount - 1;
      setPeopleCount(newCount);
      onChange?.(newCount);
    } else {
      triggerVibration();
    }
  };

  const triggerVibration = () => {
    setIsVibrating(true);
    setTimeout(() => setIsVibrating(false), 300);
  };

  const handleKeyDown = (
    e: React.KeyboardEvent,
    action: "increment" | "decrement"
  ) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      action === "increment" ? handleIncrement(e) : handleDecrement(e);
    }
  };

  const renderAvatars = () =>
    TEAM_MEMBERS.slice(0, peopleCount).map((member, index) => (
      <motion.div
        animate="animate"
        className="flex items-center justify-center"
        exit="exit"
        initial="initial"
        key={member.id}
        style={{
          zIndex: peopleCount - index,
          marginLeft: index === 0 ? 0 : -24,
        }}
        variants={animations.avatar}
      >
        <Image
          alt={member.name}
          className="rounded-full border-[0.5px] border-white/10 bg-gradient-to-b from-white/5 to-white/20 object-cover shadow-[0_8px_28px_-6px/0.2] backdrop-blur-sm transition-all duration-300 ease-fluid hover:shadow-[0_12px_32px_-8px/0.3] dark:border-white/5 dark:from-white/5 dark:to-black/20 dark:shadow-[0_8px_28px_-6px/0.3] dark:hover:shadow-[0_12px_32px_-8px/0.4]"
          height={96}
          src={member.avatarUrl}
          width={96}
        />
      </motion.div>
    ));

  return (
    <motion.div
      animate="animate"
      className={`flex w-full flex-col items-center justify-center gap-8 ${className}`}
      exit="exit"
      initial="initial"
      variants={animations.container}
    >
      <fieldset className="w-full">
        <legend className="sr-only">Team size selector</legend>
        <div className="relative flex h-24 w-full justify-center">
          <AnimatePresence mode="popLayout">{renderAvatars()}</AnimatePresence>
        </div>

        <motion.div
          animate={isVibrating ? "animate" : "initial"}
          className="mt-8 flex items-center justify-center gap-8"
          initial="initial"
          variants={isVibrating ? animations.vibration : undefined}
        >
          <button
            aria-label="Decrease team size"
            className="h-12 w-12 rounded-full border border-zinc-200/80 bg-gradient-to-b from-white to-zinc-50 text-zinc-900 shadow-[0_2px_8px_-2px/0.1] transition-all duration-200 ease-fluid hover:border-zinc-300 hover:shadow-[0_4px_12px_-4px/0.2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/30 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:from-zinc-50 active:to-zinc-100 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:from-white disabled:active:to-zinc-50 disabled:hover:border-zinc-200/80 disabled:hover:shadow-[0_2px_8px_-2px/0.1] dark:border-zinc-700/80 dark:from-zinc-800 dark:to-zinc-900 dark:text-zinc-100 dark:shadow-[0_2px_8px_-2px/0.3] dark:active:from-zinc-900 dark:active:to-zinc-800 dark:disabled:active:from-zinc-800 dark:disabled:active:to-zinc-900 dark:focus-visible:ring-zinc-400/30 dark:focus-visible:ring-offset-zinc-900 dark:hover:border-zinc-600 dark:hover:shadow-[0_4px_12px_-4px/0.4] dark:disabled:hover:border-zinc-700/80 dark:disabled:hover:shadow-[0_2px_8px_-2px/0.3]"
            disabled={peopleCount <= 1}
            onClick={handleDecrement}
            onKeyDown={(e) => handleKeyDown(e, "decrement")}
            type="button"
          >
            <span className="select-none font-medium text-2xl">-</span>
          </button>

          <motion.output
            animate="animate"
            aria-label={`Current team size: ${peopleCount}`}
            className="select-none bg-gradient-to-b from-zinc-800 to-zinc-600 bg-clip-text font-medium text-2xl text-transparent dark:from-zinc-200 dark:to-zinc-400"
            initial="initial"
            key={peopleCount}
            variants={animations.number}
          >
            {peopleCount}
          </motion.output>

          <button
            aria-label="Increase team size"
            className="h-12 w-12 rounded-full border border-zinc-200/80 bg-gradient-to-b from-white to-zinc-50 text-zinc-900 shadow-[0_2px_8px_-2px/0.1] transition-all duration-200 ease-fluid hover:border-zinc-300 hover:shadow-[0_4px_12px_-4px/0.2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/30 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:from-zinc-50 active:to-zinc-100 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:from-white disabled:active:to-zinc-50 disabled:hover:border-zinc-200/80 disabled:hover:shadow-[0_2px_8px_-2px/0.1] dark:border-zinc-700/80 dark:from-zinc-800 dark:to-zinc-900 dark:text-zinc-100 dark:shadow-[0_2px_8px_-2px/0.3] dark:active:from-zinc-900 dark:active:to-zinc-800 dark:disabled:active:from-zinc-800 dark:disabled:active:to-zinc-900 dark:focus-visible:ring-zinc-400/30 dark:focus-visible:ring-offset-zinc-900 dark:hover:border-zinc-600 dark:hover:shadow-[0_4px_12px_-4px/0.4] dark:disabled:hover:border-zinc-700/80 dark:disabled:hover:shadow-[0_2px_8px_-2px/0.3]"
            disabled={peopleCount >= MAX_TEAM_SIZE}
            onClick={handleIncrement}
            onKeyDown={(e) => handleKeyDown(e, "increment")}
            type="button"
          >
            <span className="select-none font-medium text-2xl">+</span>
          </button>
        </motion.div>
      </fieldset>
    </motion.div>
  );
}

Installation

npx shadcn@latest add @kokonutui/team-selector

Usage

import { TeamSelector } from "@/components/team-selector"
<TeamSelector />