Native Likes Counter

PreviousNext

An interactive likes counter with avatar stack, popup details, and smooth animations.

Docs
uitripledcomponent

Preview

Loading preview…
components/native/shadcnui/native-likes-counter-shadcnui.tsx
"use client"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
import { AnimatePresence, motion } from "framer-motion"
import { Heart, Loader2 } from "lucide-react"
import { useState, useCallback, useRef } from "react"

export interface LikeUser {
  id: string
  name: string
  avatar?: string
}

export interface NativeLikesCounterProps {
  count: number
  users?: LikeUser[]
  variant?: "default" | "subtle" | "outline" | "ghost"
  size?: "sm" | "default" | "lg"
  liked?: boolean
  onLike?: () => void
  onLoadMore?: () => Promise<LikeUser[]> | LikeUser[]
  hasMore?: boolean
  maxAvatars?: number
  maxVisibleInPopup?: number
  className?: string
}

const sizeVariants = {
  sm: {
    container: "h-7 px-2.5 gap-1.5 text-xs",
    icon: "w-3.5 h-3.5",
    avatar: "w-4 h-4",
    avatarStack: "-space-x-1",
    popup: "p-3",
    popupAvatar: "w-6 h-6",
  },
  default: {
    container: "h-8 px-3 gap-2 text-sm",
    icon: "w-4 h-4",
    avatar: "w-5 h-5",
    avatarStack: "-space-x-1.5",
    popup: "p-3",
    popupAvatar: "w-7 h-7",
  },
  lg: {
    container: "h-9 px-3.5 gap-2 text-sm",
    icon: "w-[18px] h-[18px]",
    avatar: "w-6 h-6",
    avatarStack: "-space-x-2",
    popup: "p-3",
    popupAvatar: "w-8 h-8",
  },
}

export function NativeLikesCounter({
  count,
  users = [],
  variant = "default",
  size = "default",
  liked = false,
  onLike,
  onLoadMore,
  hasMore = false,
  maxAvatars = 5,
  maxVisibleInPopup = 5,
  className,
}: NativeLikesCounterProps) {
  const [isHovered, setIsHovered] = useState(false)
  const [isLiked, setIsLiked] = useState(liked)
  const [localCount, setLocalCount] = useState(count)
  const [loadedUsers, setLoadedUsers] = useState<LikeUser[]>(users)
  const [isLoadingMore, setIsLoadingMore] = useState(false)
  const [canLoadMore, setCanLoadMore] = useState(hasMore)

  const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)

  const sizeConfig = sizeVariants[size]
  const displayUsers = loadedUsers.slice(0, maxAvatars)

  const handleMouseEnter = () => {
    if (hoverTimeoutRef.current) {
      clearTimeout(hoverTimeoutRef.current)
      hoverTimeoutRef.current = null
    }
    setIsHovered(true)
  }

  const handleMouseLeave = () => {
    hoverTimeoutRef.current = setTimeout(() => {
      setIsHovered(false)
    }, 150) // Small delay to allow moving to popup
  }

  const handleLike = () => {
    setIsLiked(!isLiked)
    setLocalCount((prev) => (isLiked ? prev - 1 : prev + 1))
    onLike?.()
  }

  const handleLoadMore = useCallback(async () => {
    if (!onLoadMore || isLoadingMore) return

    setIsLoadingMore(true)
    try {
      const newUsers = await onLoadMore()
      if (newUsers.length === 0) {
        setCanLoadMore(false)
      } else {
        setLoadedUsers((prev) => [...prev, ...newUsers])
      }
    } catch (error) {
      console.error("Failed to load more users:", error)
    } finally {
      setIsLoadingMore(false)
    }
  }, [onLoadMore, isLoadingMore])

  const getVariantStyles = () => {
    const base = "transition-all duration-150"
    switch (variant) {
      case "subtle":
        return cn(base, "bg-accent/50 hover:bg-accent", isLiked && "bg-accent")
      case "outline":
        return cn(
          base,
          "bg-transparent border border-border hover:border-accent-foreground/20 hover:bg-accent/10",
          isLiked && "border-accent-foreground/30 bg-accent/20",
        )
      case "ghost":
        return cn(base, "bg-transparent hover:bg-accent/50", isLiked && "bg-accent/30")
      default:
        return cn(
          base,
          "bg-accent border border-border hover:bg-accent/80 hover:border-accent-foreground/20",
          isLiked && "border-accent-foreground/20",
        )
    }
  }

  const visibleUsersInPopup = loadedUsers.slice(0, maxVisibleInPopup)
  const totalRemaining = localCount - loadedUsers.length

  return (
    <div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
      <motion.button
        onClick={handleLike}
        className={cn(
          "relative flex items-center rounded-md font-medium",
          sizeConfig.container,
          getVariantStyles(),
          className,
        )}
        whileTap={{ scale: 0.98 }}
        transition={{ duration: 0.1 }}
      >
        {/* Heart icon */}
        <motion.div className="relative flex items-center justify-center">
          <motion.div animate={isLiked ? { scale: [1, 1.15, 1] } : { scale: 1 }} transition={{ duration: 0.2 }}>
            <Heart
              className={cn(
                sizeConfig.icon,
                "transition-colors duration-150",
                isLiked ? "fill-red-500 text-red-500" : "text-muted-foreground",
              )}
            />
          </motion.div>
        </motion.div>

        <AnimatePresence mode="popLayout">
          <motion.span
            key={localCount}
            initial={{ y: -8, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            exit={{ y: 8, opacity: 0 }}
            transition={{ duration: 0.15 }}
            className={cn("font-medium tabular-nums", isLiked ? "text-foreground" : "text-muted-foreground")}
          >
            {localCount.toLocaleString()}
          </motion.span>
        </AnimatePresence>

        {users.length > 0 && variant !== "ghost" && (
          <div className={cn("flex items-center", sizeConfig.avatarStack)}>
            {users.slice(0, 3).map((user, index) => (
              <motion.div
                key={user.id}
                initial={{ scale: 0, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                transition={{ delay: index * 0.03, duration: 0.15 }}
              >
                <Avatar className={cn(sizeConfig.avatar, "border border-background ring-1 ring-border")}>
                  <AvatarImage src={user.avatar || "/placeholder.svg"} alt={user.name} className="object-cover" />
                  <AvatarFallback className="text-[9px] bg-accent text-muted-foreground">
                    {user.name.charAt(0).toUpperCase()}
                  </AvatarFallback>
                </Avatar>
              </motion.div>
            ))}
          </div>
        )}
      </motion.button>

      <AnimatePresence>
        {isHovered && loadedUsers.length > 0 && (
          <motion.div
            initial={{ opacity: 0, y: 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 4 }}
            transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}
            className={cn(
              "absolute left-1/2 -translate-x-1/2 bottom-full mb-1 z-[100]",
              "bg-popover border border-border rounded-lg shadow-2xl",
              "w-[240px]",
              sizeConfig.popup,
            )}
          >
            {/* Header */}
            <div className="flex items-center justify-between mb-2 px-1">
              <span className="text-xs font-medium text-muted-foreground">Liked by</span>
              <span className="text-xs font-mono text-muted-foreground/60">{localCount.toLocaleString()}</span>
            </div>

            <div className="max-h-[140px] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
              <div className="space-y-1 px-1">
                {visibleUsersInPopup.map((user, index) => (
                  <motion.div
                    key={user.id}
                    initial={{ opacity: 0, x: -8 }}
                    animate={{ opacity: 1, x: 0 }}
                    transition={{
                      delay: index * 0.02,
                      duration: 0.15,
                      ease: [0.23, 1, 0.32, 1],
                    }}
                    className="flex items-center gap-2 py-1 group"
                  >
                    <Avatar className={cn(sizeConfig.popupAvatar, "border border-border shrink-0")}>
                      <AvatarImage src={user.avatar || "/placeholder.svg"} alt={user.name} className="object-cover" />
                      <AvatarFallback className="text-[10px] bg-accent text-muted-foreground">
                        {user.name.charAt(0).toUpperCase()}
                      </AvatarFallback>
                    </Avatar>
                    <span className="text-xs text-foreground/80 group-hover:text-foreground transition-colors truncate">
                      {user.name}
                    </span>
                  </motion.div>
                ))}
              </div>
            </div>

            {(canLoadMore || totalRemaining > 0) && (
              <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                transition={{ delay: visibleUsersInPopup.length * 0.02 }}
                className="mt-2 pt-2 border-t border-border/50"
              >
                {onLoadMore && canLoadMore ? (
                  <button
                    onClick={(e) => {
                      e.stopPropagation()
                      handleLoadMore()
                    }}
                    disabled={isLoadingMore}
                    className="w-full flex items-center justify-center gap-1.5 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
                  >
                    {isLoadingMore ? (
                      <>
                        <Loader2 className="w-3 h-3 animate-spin" />
                        <span>Loading...</span>
                      </>
                    ) : (
                      <span>Load more {totalRemaining > 0 && `(${totalRemaining.toLocaleString()} more)`}</span>
                    )}
                  </button>
                ) : totalRemaining > 0 ? (
                  <div className="flex items-center justify-center py-1">
                    <span className="text-xs text-muted-foreground/60">+{totalRemaining.toLocaleString()} others</span>
                  </div>
                ) : null}
              </motion.div>
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  )
}

Installation

npx shadcn@latest add @uitripled/native-likes-counter-shadcnui

Usage

import { NativeLikesCounterShadcnui } from "@/components/native-likes-counter-shadcnui"
<NativeLikesCounterShadcnui />