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.
Who?
Just shipped an amazing new feature! ๐
Installation
npx shadcn@latest add @grenish/tweet-cardThis 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 itemUsage
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,xlwith 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.
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.
Just shipped an amazing new feature! ๐
<TweetCard
size="md"
profileName="Sarah Chen"
tweet="Just shipped an amazing new feature! ๐"
likes={1200}
/>Large
Prominent display variant for featured content and spotlights.
Creator & Developer
Building in public is the best way to learn and grow.
<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.
Beautiful sunset at the beach ๐
<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.
Before and after comparison shots
<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
Full-Featured Tweet
All available props combined for maximum detail.
Product Designer at TechCorp
Excited to share our latest design system update! We've improved accessibility across all components. ๐จโจ
<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.
Simple and clean
<TweetCard size="sm" profileName="Quick Tweet" tweet="Simple and clean" />Tweet with Timestamp
Display tweet date and time information for context.
Journalist
Breaking: New industry report just released
<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}
/>