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.
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
"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 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]);
}