Todos os componentes

Figma Comment

Figma Comment é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT other

O que é

Figma Comment é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Figma Comment

index.tsx
"use client";

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { getImageKitUrl } from "@/lib/smoothui-data";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useClickOutside } from "./use-click-outside";

const CLOSED_SIZE = 32;
const AVATAR_CLOSED_LEFT = 4;
const AVATAR_CLOSED_TOP = 4;
const AVATAR_OPEN_LEFT = 12;
const AVATAR_OPEN_TOP = 12;
const CONTENT_DELAY = 0.15;
const INITIAL_BLUR_PX = 6;
const EXIT_BLUR_PX = 3;
const MEASURE_DELAY_SHORT = 100;
const MEASURE_DELAY_LONG = 500;
const CONTAINER_CLOSE_DELAY = 0.08;
const _BLUR_DURATION = 0.4;
const BLUR_EASE_X1 = 0.22;
const BLUR_EASE_Y1 = 1;
const BLUR_EASE_X2 = 0.36;
const BLUR_EASE_Y2 = 1;
const BLUR_EASE: [number, number, number, number] = [
  BLUR_EASE_X1,
  BLUR_EASE_Y1,
  BLUR_EASE_X2,
  BLUR_EASE_Y2,
];

export interface FigmaCommentProps {
  authorName?: string;
  avatarAlt?: string;
  avatarUrl?: string;
  className?: string;
  message?: string;
  onOpenChange?: (isOpen: boolean) => void;
  timestamp?: string;
  width?: number;
}

export default function FigmaComment({
  avatarUrl = getImageKitUrl(
    "https://ik.imagekit.io/16u211libb/avatar-educalvolpz.jpeg?updatedAt=1765524159631",
    {
      format: "auto",
      height: 48,
      quality: 85,
      width: 48,
    }
  ),
  avatarAlt = "Avatar",
  className,
  authorName = "Edu Calvo",
  timestamp = "Just now",
  message = "What happens if we adjust this to handle a light and dark mode? I'm not sure if we're ready to handle...",
  width = 180,
  onOpenChange,
}: FigmaCommentProps) {
  const [isOpen, setIsOpen] = useState(false);
  const contentRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const [contentHeight, setContentHeight] = useState(CLOSED_SIZE);
  const shouldReduceMotion = useReducedMotion();

  // Close comment when clicking outside
  useClickOutside(containerRef, () => {
    if (isOpen) {
      setIsOpen(false);
    }
  });

  // Notify parent of open state changes
  useEffect(() => {
    onOpenChange?.(isOpen);
  }, [isOpen, onOpenChange]);

  // Measure content height when component mounts or message changes
  useEffect(() => {
    const measureHeight = () => {
      if (contentRef.current) {
        const innerDiv = contentRef.current.firstElementChild as HTMLElement;
        if (innerDiv) {
          const height = innerDiv.scrollHeight;
          if (height > 0) {
            setContentHeight(height);
          }
        }
      }
    };

    // Use setTimeout to ensure DOM is fully rendered
    const timeoutId = setTimeout(measureHeight, MEASURE_DELAY_SHORT);
    const timeoutId2 = setTimeout(measureHeight, MEASURE_DELAY_LONG);
    return () => {
      clearTimeout(timeoutId);
      clearTimeout(timeoutId2);
    };
  }, []);

  const handleToggle = () => {
    setIsOpen((prev) => !prev);
  };

  return (
    <div className={cn("relative", className)}>
      <motion.div
        animate={
          shouldReduceMotion
            ? {}
            : {
                height: isOpen ? contentHeight : CLOSED_SIZE,
                width: isOpen ? width : CLOSED_SIZE,
              }
        }
        className="absolute bottom-0 left-0 cursor-pointer overflow-hidden rounded-2xl rounded-bl-none bg-background shadow-[0px_0px_0.5px_0px_rgba(0,0,0,0.18),0px_3px_8px_0px_rgba(0,0,0,0.1),0px_1px_3px_0px_rgba(0,0,0,0.1)]"
        onClick={handleToggle}
        ref={containerRef}
        style={
          shouldReduceMotion
            ? {
                height: isOpen ? contentHeight : CLOSED_SIZE,
                width: isOpen ? width : CLOSED_SIZE,
              }
            : undefined
        }
        transition={
          shouldReduceMotion
            ? { duration: 0 }
            : {
                damping: 45,
                delay: isOpen ? 0 : CONTAINER_CLOSE_DELAY,
                duration: 0.25,
                mass: 0.7,
                stiffness: 550,
                type: "spring" as const,
              }
        }
      >
        {/* Avatar - animates position */}
        <motion.div
          animate={
            shouldReduceMotion
              ? {}
              : {
                  left: isOpen ? AVATAR_OPEN_LEFT : AVATAR_CLOSED_LEFT,
                  top: isOpen ? AVATAR_OPEN_TOP : AVATAR_CLOSED_TOP,
                }
          }
          className="absolute z-10"
          style={
            shouldReduceMotion
              ? {
                  left: isOpen ? AVATAR_OPEN_LEFT : AVATAR_CLOSED_LEFT,
                  top: isOpen ? AVATAR_OPEN_TOP : AVATAR_CLOSED_TOP,
                }
              : undefined
          }
          transition={
            shouldReduceMotion
              ? { duration: 0 }
              : {
                  damping: 25,
                  duration: 0.25,
                  stiffness: 300,
                  type: "spring" as const,
                }
          }
        >
          <Avatar className="h-6 w-6">
            <AvatarImage alt={avatarAlt} src={avatarUrl} />
            <AvatarFallback>{authorName.charAt(0)}</AvatarFallback>
          </Avatar>
        </motion.div>

        {/* Content - always rendered but hidden when closed for measurement */}
        <div
          className="pointer-events-none absolute"
          ref={contentRef}
          style={{
            left: 0,
            position: "absolute",
            top: "-9999px",
            width: `${width}px`,
          }}
        >
          <div className="flex flex-col items-start gap-0.5 py-3 pr-4 pl-11">
            {/* Attribution */}
            <div className="flex items-start gap-0.5">
              <p className="font-semibold text-[11px] text-foreground leading-4">
                {authorName}
              </p>
              <p className="font-medium text-[11px] text-muted-foreground leading-4">
                {timestamp}
              </p>
            </div>
            {/* Message */}
            <p className="text-left font-medium text-[11px] text-foreground leading-4">
              {message}
            </p>
          </div>
        </div>

        {/* Content - visible when open */}
        <AnimatePresence>
          {isOpen ? (
            <motion.div
              animate={
                shouldReduceMotion
                  ? { opacity: 1 }
                  : {
                      filter: "blur(0px)",
                      opacity: 1,
                    }
              }
              className="absolute inset-0 flex flex-col items-start gap-0.5 py-3 pr-4 pl-11"
              exit={
                shouldReduceMotion
                  ? { opacity: 0, transition: { duration: 0 } }
                  : {
                      filter: `blur(${String(EXIT_BLUR_PX)}px)`,
                      opacity: 0,
                    }
              }
              initial={
                shouldReduceMotion
                  ? { opacity: 0 }
                  : {
                      filter: `blur(${String(INITIAL_BLUR_PX)}px)`,
                      opacity: 0,
                    }
              }
              style={{
                width: `${width}px`,
              }}
              transition={
                (shouldReduceMotion
                  ? { duration: 0 }
                  : (isExiting: boolean) => ({
                      filter: {
                        delay: isExiting ? 0 : CONTENT_DELAY,
                        duration: 0.25,
                        ease: BLUR_EASE,
                      },
                      opacity: {
                        delay: isExiting ? 0 : CONTENT_DELAY,
                        duration: 0.25,
                        ease: BLUR_EASE,
                      },
                    })) as import("motion/react").Transition
              }
            >
              {/* Attribution */}
              <div className="flex items-start gap-0.5">
                <p className="font-semibold text-[11px] text-foreground leading-4">
                  {authorName}
                </p>
                <p className="font-medium text-[11px] text-muted-foreground leading-4">
                  {timestamp}
                </p>
              </div>
              {/* Message */}
              <p className="text-left font-medium text-[11px] text-foreground leading-4">
                {message}
              </p>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </motion.div>
    </div>
  );
}
use-click-outside.tsx
"use client";

import { type RefObject, useEffect } from "react";

type EventType =
  | "mousedown"
  | "mouseup"
  | "touchstart"
  | "touchend"
  | "focusin"
  | "focusout";

export function useClickOutside<T extends HTMLElement = HTMLElement>(
  ref: RefObject<T | null> | RefObject<T | null>[],
  handler: (event: Event) => void,
  eventType: EventType = "mousedown"
): void {
  useEffect(() => {
    function callback(event: Event) {
      const target = event.target as Node;

      // Do nothing if the target is not connected element with document
      if (!target?.isConnected) {
        return;
      }

      const isOutside = Array.isArray(ref)
        ? ref
            .filter((r) => Boolean(r.current))
            .every((r) => r.current && !r.current.contains(target))
        : ref.current && !ref.current.contains(target);

      if (isOutside) {
        handler(event);
      }
    }

    window.addEventListener(eventType, callback);

    return () => {
      window.removeEventListener(eventType, callback);
    };
  }, [ref, handler, eventType]);
}

Componentes parecidos