Tools

Tweet Card

A versatile Twitter/X-style card component for displaying tweets with profile information, media galleries, and engagement metrics.

Last updated on

A beautifully designed card component inspired by Twitter/X's tweet layout. Display user profiles, tweet content, media galleries, and engagement statistics in a customizable, responsive format.

GR
Grenish Rai

Who?

Just shipped an amazing new feature! ๐Ÿš€

12:23 AM ยท Apr 18, 2026
3K4K20K

Installation

npx shadcn@latest add @grenish/tweet-card

This requires the @grenish registry in your components.json. See the installation guide for setup.

Manual Dependencies

If you prefer manual installation, add the base components:

npx shadcn@latest add card avatar button aspect-ratio item

Usage

import TweetCard from "@/components/tools/tweet-card";

export default function Demo() {
  return (
    <TweetCard
      profileName="Sarah Chen"
      bio="Product Designer"
      tweet="Just shipped an amazing new feature! ๐Ÿš€"
      likes={1200}
      retweets={340}
      replies={85}
    />
  );
}
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";
import { cn } from "@/lib/utils";
import {
  ChatCircleIcon,
  HeartIcon,
  RepeatIcon,
  XLogoIcon,
} from "@phosphor-icons/react/dist/ssr";
import Image from "next/image";

// Types

type TweetCardSize = "sm" | "md" | "lg" | "xl";

interface TweetCardProps {
  avatarSrc?: string;
  className?: string;
  bio?: string;
  profileName: string;
  tweet?: string;
  timestamp?: string;
  media?: string[];
  likes?: number;
  retweets?: number;
  replies?: number;
  size?: TweetCardSize;
}

interface SizeConfig {
  avatar: string;
  buttonSize: "icon-xs" | "icon-sm" | "icon" | "icon-lg";
  card: string;
  cardSize: "default" | "sm";
  description: string;
  gap: string;
  inset: string;
  itemSize: "default" | "sm";
  mediaRadius: string;
  metadata: string;
  title: string;
  tweet: string;
}

interface TweetCardHeaderProps {
  avatarSrc?: string;
  avatarClass: string;
  bio?: string;
  buttonSize: "icon-xs" | "icon-sm" | "icon" | "icon-lg";
  descriptionClass: string;
  initials: string;
  inset: string;
  itemSize: "default" | "sm";
  profileName: string;
  titleClass: string;
}

interface TweetCardBodyProps {
  gap: string;
  inset: string;
  likes?: number;
  media: string[];
  mediaRadius: string;
  metadata: string;
  replies?: number;
  retweets?: number;
  timestamp?: string;
  tweet?: string;
  tweetClass: string;
}

// Config

const sizeConfig: Record<TweetCardSize, SizeConfig> = {
  sm: {
    avatar: "size-9",
    buttonSize: "icon-xs",
    card: "max-w-xs",
    cardSize: "sm",
    description: "text-xs",
    gap: "gap-1.5",
    inset: "px-4",
    itemSize: "sm",
    mediaRadius: "rounded-xl",
    metadata: "text-xs",
    title: "text-sm",
    tweet: "text-xs",
  },
  md: {
    avatar: "size-10",
    buttonSize: "icon-sm",
    card: "max-w-sm",
    cardSize: "default",
    description: "text-sm",
    gap: "gap-2",
    inset: "px-5",
    itemSize: "default",
    mediaRadius: "rounded-2xl",
    metadata: "text-xs",
    title: "text-sm",
    tweet: "text-sm",
  },
  lg: {
    avatar: "size-12",
    buttonSize: "icon",
    card: "max-w-md",
    cardSize: "default",
    description: "text-sm",
    gap: "gap-2.5",
    inset: "px-6",
    itemSize: "default",
    mediaRadius: "rounded-2xl",
    metadata: "text-sm",
    title: "text-base",
    tweet: "text-base",
  },
  xl: {
    avatar: "size-14",
    buttonSize: "icon-lg",
    card: "max-w-xl",
    cardSize: "default",
    description: "text-sm",
    gap: "gap-3",
    inset: "px-6",
    itemSize: "default",
    mediaRadius: "rounded-2xl",
    metadata: "text-sm",
    title: "text-base",
    tweet: "text-lg",
  },
};

const mediaLayoutClass: Record<number, string> = {
  1: "grid-cols-1",
  2: "grid-cols-2",
  3: "grid-cols-[1fr_1fr] grid-rows-2",
  4: "grid-cols-2 grid-rows-2",
};

// Utilities

function getAvatarInitials(name: string): string {
  return name
    .split(" ")
    .filter(Boolean)
    .slice(0, 2)
    .map((word) => word[0])
    .join("")
    .toUpperCase();
}

function formatCount(count: number | undefined): string {
  if (!count) return "0";
  if (count >= 1_000_000) {
    return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
  }
  if (count >= 1_000) {
    return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
  }
  return count.toString();
}

// Components

function TweetCardHeader({
  avatarSrc,
  avatarClass,
  bio,
  buttonSize,
  descriptionClass,
  initials,
  inset,
  itemSize,
  profileName,
  titleClass,
}: TweetCardHeaderProps) {
  return (
    <Item
      size={itemSize}
      className={cn("border-0 rounded-none bg-transparent py-0", inset)}
    >
      <ItemMedia>
        <Avatar className={avatarClass}>
          <AvatarImage src={avatarSrc} alt={profileName} />
          <AvatarFallback>{initials}</AvatarFallback>
        </Avatar>
      </ItemMedia>
      <ItemContent>
        <ItemTitle className={titleClass}>{profileName}</ItemTitle>
        {bio && (
          <ItemDescription
            className={cn(descriptionClass, "text-muted-foreground")}
          >
            {bio}
          </ItemDescription>
        )}
      </ItemContent>
      <ItemActions>
        <Button variant="outline" size={buttonSize} aria-label="Open tweet">
          <XLogoIcon weight="duotone" />
        </Button>
      </ItemActions>
    </Item>
  );
}

function TweetCardBody({
  gap,
  inset,
  likes,
  media,
  mediaRadius,
  metadata,
  replies,
  retweets,
  timestamp,
  tweet,
  tweetClass,
}: TweetCardBodyProps) {
  const hasEngagementStats =
    likes !== undefined || retweets !== undefined || replies !== undefined;

  return (
    <CardContent className={cn("pt-0", inset)}>
      <div className={cn("flex flex-col", gap)}>
        {tweet && <p className={tweetClass}>{tweet}</p>}

        {media.length > 0 && (
          <div
            className={cn(
              "grid w-full overflow-hidden aspect-video mt-2",
              gap,
              mediaRadius,
              mediaLayoutClass[media.length],
            )}
          >
            {media.map((src, index) => (
              <div
                key={`${src}-${index}`}
                className={cn(
                  "relative min-h-0 overflow-hidden",
                  media.length === 3 && index === 0 && "row-span-2",
                )}
              >
                <Image
                  src={src}
                  alt={`Tweet media ${index + 1}`}
                  fill
                  className="object-cover"
                  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
                />
              </div>
            ))}
          </div>
        )}

        {(timestamp || hasEngagementStats) && (
          <div
            className={cn(
              "flex items-center justify-between text-muted-foreground border-t border-border/40 pt-2",
              metadata,
            )}
          >
            {timestamp && <span>{timestamp}</span>}
            {hasEngagementStats && (
              <div className="flex gap-4">
                {replies !== undefined && (
                  <span className="flex items-center gap-1">
                    {formatCount(replies)}
                    <ChatCircleIcon className="size-3.5" />
                  </span>
                )}
                {retweets !== undefined && (
                  <span className="flex items-center gap-1">
                    {formatCount(retweets)}
                    <RepeatIcon className="size-3.5" />
                  </span>
                )}
                {likes !== undefined && (
                  <span className="flex items-center gap-1">
                    {formatCount(likes)}
                    <HeartIcon className="size-3.5" />
                  </span>
                )}
              </div>
            )}
          </div>
        )}
      </div>
    </CardContent>
  );
}

export default function TweetCard({
  avatarSrc,
  className,
  bio,
  profileName,
  tweet,
  timestamp,
  media = [],
  likes,
  retweets,
  replies,
  size = "md",
}: TweetCardProps) {
  const config = sizeConfig[size];
  const initials = getAvatarInitials(profileName);

  return (
    <Card
      size={config.cardSize}
      className={cn(
        "w-full border border-border/60 bg-card/95 shadow-sm",
        config.card,
        className,
      )}
    >
      <TweetCardHeader
        avatarClass={config.avatar}
        avatarSrc={avatarSrc}
        bio={bio}
        buttonSize={config.buttonSize}
        descriptionClass={config.description}
        initials={initials}
        inset={config.inset}
        itemSize={config.itemSize}
        profileName={profileName}
        titleClass={config.title}
      />
      <TweetCardBody
        gap={config.gap}
        inset={config.inset}
        likes={likes}
        media={media}
        mediaRadius={config.mediaRadius}
        metadata={config.metadata}
        replies={replies}
        retweets={retweets}
        timestamp={timestamp}
        tweet={tweet}
        tweetClass={config.tweet}
      />
    </Card>
  );
}

Features

  • 4 Size Variants: sm, md, lg, xl with proportional scaling
  • Rich Content: Support for text, media galleries, and engagement metrics
  • Flexible Media: 1, 2, 3, or 4 image layouts with responsive grids
  • Smart Formatting: Automatic number formatting for engagement metrics (K, M notation)
  • Timestamps: Optional tweet timestamp display
  • User Profile: Avatar, name, and bio with fallback initials
  • Responsive Design: Adapts seamlessly to all screen sizes
  • Accessible: Semantic HTML with proper ARIA labels
  • Type-Safe: Fully typed with TypeScript interfaces

Props

Prop

Type

Size Variants

Each size maintains perfect proportions with scaled typography, spacing, and imagery.

Small

Compact variant suitable for sidebars, lists, and narrow spaces.

AK
Alex Kim

Great day for coding!

<TweetCard size="sm" profileName="Alex Kim" tweet="Great day for coding!" />

Medium (Default)

Balanced size for general use and content areas.

SC
Sarah Chen

Just shipped an amazing new feature! ๐Ÿš€

1.2K
<TweetCard
  size="md"
  profileName="Sarah Chen"
  tweet="Just shipped an amazing new feature! ๐Ÿš€"
  likes={1200}
/>

Large

Prominent display variant for featured content and spotlights.

JL
Jordan Lee

Creator & Developer

Building in public is the best way to learn and grow.

Nov 15, 2024
2458903.5K
<TweetCard
  size="lg"
  profileName="Jordan Lee"
  bio="Creator & Developer"
  tweet="Building in public is the best way to learn and grow."
  timestamp="Nov 15, 2024"
  likes={3500}
  retweets={890}
  replies={245}
/>

Media Layouts

The component automatically arranges media based on the number of images provided. Excess images beyond the supported count are ignored.

Single Image

Display one featured image with 16:9 aspect ratio.

EW
Emma Wilson

Beautiful sunset at the beach ๐ŸŒ…

Tweet media 1
2.1K
<TweetCard
  profileName="Emma Wilson"
  tweet="Beautiful sunset at the beach ๐ŸŒ…"
  media={[
    "https://images.unsplash.com/photo-1495567720989-cebdbdd97913?q=80&w=1200&auto=format&fit=crop",
  ]}
/>

Two Images

Side-by-side image arrangement with equal columns.

CP
Chris Park

Before and after comparison shots

Tweet media 1
Tweet media 2
234890
<TweetCard
  profileName="Chris Park"
  tweet="Before and after comparison shots"
  media={[
    "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=1200&auto=format&fit=crop",
    "https://images.unsplash.com/photo-1488646953014-85cb44e25828?q=80&w=1200&auto=format&fit=crop",
  ]}
/>

Examples

All available props combined for maximum detail.

ER
Elena Rodriguez

Product Designer at TechCorp

Excited to share our latest design system update! We've improved accessibility across all components. ๐ŸŽจโœจ

Tweet media 1
Tweet media 2
Nov 15, 2024
3801.2K4.5K
<TweetCard
  profileName="Elena Rodriguez"
  bio="Product Designer at TechCorp"
  avatarSrc="https://github.com/shadcn.png"
  tweet="Excited to share our latest design system update! We've improved accessibility across all components. ๐ŸŽจโœจ"
  timestamp="Nov 15, 2024"
  media={[
    "https://images.unsplash.com/photo-1557821552-17105176677c?q=80&w=1200&auto=format&fit=crop",
    "https://images.unsplash.com/photo-1552664730-d307ca884978?q=80&w=1200&auto=format&fit=crop",
  ]}
  likes={4500}
  retweets={1200}
  replies={380}
  size="lg"
/>

Minimal Tweet

Bare-bones usage with just a profile and tweet text.

QT
Quick Tweet

Simple and clean

<TweetCard size="sm" profileName="Quick Tweet" tweet="Simple and clean" />

Tweet with Timestamp

Display tweet date and time information for context.

NR
News Reporter

Journalist

Breaking: New industry report just released

Nov 15, 2024 โ€ข 2:30 PM
2901.4K5.6K
<TweetCard
  profileName="News Reporter"
  bio="Journalist"
  tweet="Breaking: New industry report just released"
  timestamp="Nov 15, 2024 โ€ข 2:30 PM"
  likes={5600}
  retweets={1400}
  replies={290}
/>

On this page