reelcn
GitHubBack to landing

Comment bubble

A viewer comment card, optionally marked as a reply, with a like count that ticks up.

CommentBubble, defaults
CommentBubble, defaults thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/comment-bubble.json

Files land in src/reelcn/ with the items it builds on.

Usage

<Center>
  <CommentBubble name="Katherine" handle="@kj" text="Which mic is that?" replyTo="Ada" likes={42} />
</Center>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
name*string
text*string
handlestring
replyTostringName this comment answers; shown as a small "Replying to" line.
likesnumber
avatarReactNodeAvatar image; pass an `<Img>`. Initials are drawn when omitted.
widthnumber Default 700.
styleCSSProperties
classNamestring

Built from

Use

  • Answering a comment on camera
  • Showing the question a video is about to answer

Avoid

  • A full post with author metrics. Use post-card

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/comment-bubble.tsx
/**
 * @title Comment Bubble
 * @category social
 * @description A viewer comment card, optionally marked as a reply, with a like count that ticks up.
 * @duration 36
 * @use Answering a comment on camera
 * @use Showing the question a video is about to answer
 * @avoid A full post with author metrics. Use `post-card`
 * @tags comment, reply, community, card
 * @example
 * <Center>
 *   <CommentBubble name="Katherine" handle="@kj" text="Which mic is that?" replyTo="Ada" likes={42} />
 * </Center>
 */
import type React from "react";
import { alpha, graphemeInitial, type MotionProps, tween, useMotion, useTheme, useViewport } from "./core";

export type CommentBubbleProps = MotionProps & {
  name: string;
  text: string;
  handle?: string;
  /** Name this comment answers; shown as a small "Replying to" line. */
  replyTo?: string;
  likes?: number;
  /** Avatar image; pass an `<Img>`. Initials are drawn when omitted. */
  avatar?: React.ReactNode;
  width?: number;
  style?: React.CSSProperties;
  className?: string;
};

export function CommentBubble({
  name,
  text,
  handle,
  replyTo,
  likes,
  avatar,
  width = 700,
  style,
  className,
  ...motion
}: CommentBubbleProps) {
  const theme = useTheme();
  const { u } = useViewport();
  const m = useMotion(motion);
  const enter = Math.min(m.enter, 1);
  const count = tween(m.frame, m.fps, {
    from: m.delay + Math.round(m.fps * 0.4),
    duration: Math.round(m.fps * 0.9),
    motion: "gentle",
  });

  return (
    <div
      className={className}
      style={{
        display: "flex",
        gap: u(16),
        width: u(width),
        maxWidth: "100%",
        color: theme.colors.foreground,
        opacity: enter * (1 - m.exit),
        translate: `${(1 - enter) * -u(18)}px 0`,
        ...style,
      }}
    >
      <div
        style={{
          width: u(56),
          height: u(56),
          borderRadius: "50%",
          overflow: "hidden",
          background: theme.colors.accent,
          color: theme.colors.accentForeground,
          display: "grid",
          placeItems: "center",
          fontSize: u(24),
          fontWeight: 700,
          flexShrink: 0,
        }}
      >
        {avatar ?? graphemeInitial(name)}
      </div>
      <div
        style={{
          flexGrow: 1,
          padding: u(20),
          borderRadius: u(theme.radius),
          background: alpha(theme.colors.surface, 0.95),
          border: `1px solid ${theme.colors.border}`,
        }}
      >
        {replyTo ? (
          <div style={{ fontSize: u(18), color: theme.colors.accent, marginBottom: u(6) }}>Replying to {replyTo}</div>
        ) : null}
        <div style={{ display: "flex", alignItems: "baseline", gap: u(10) }}>
          <span style={{ fontSize: u(24), fontWeight: 700 }}>{name}</span>
          {handle ? <span style={{ fontSize: u(20), color: theme.colors.muted }}>{handle}</span> : null}
        </div>
        <div style={{ fontSize: u(26), lineHeight: 1.4, marginTop: u(8), textWrap: "pretty" }}>{text}</div>
        {likes === undefined ? null : (
          <div
            style={{ display: "flex", alignItems: "center", gap: u(8), marginTop: u(14), color: theme.colors.muted }}
          >
            <svg width={u(20)} height={u(20)} viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path
                d="M12 20s-7-4.5-7-9.5A3.5 3.5 0 0 1 12 8a3.5 3.5 0 0 1 7 2.5C19 15.5 12 20 12 20Z"
                stroke="currentColor"
                strokeWidth={1.8}
                strokeLinejoin="round"
              />
            </svg>
            <span style={{ fontSize: u(20), fontVariantNumeric: "tabular-nums" }}>
              {Math.round(likes * Math.min(count, 1))}
            </span>
          </div>
        )}
      </div>
    </div>
  );
}

On this page