Tweet Card
Tweet Card is a React and Tailwind CSS card component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
cards
What it is
Tweet Card is a React and Tailwind CSS card component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i react-tweet
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Tweet Card
"use client";
import { type TweetProps, useTweet } from "react-tweet";
import { SmoothTweet, TweetNotFound, TweetSkeleton } from "./index";
export type ClientTweetCardProps = TweetProps & {
className?: string;
userInfoPosition?: "top" | "bottom";
avatarRounded?: string;
};
export const ClientTweetCard = ({
id,
apiUrl,
fallback = <TweetSkeleton />,
components,
fetchOptions,
onError,
...props
}: ClientTweetCardProps) => {
const { data, error, isLoading } = useTweet(id, apiUrl, fetchOptions);
if (isLoading) {
return fallback;
}
if (error || !data) {
const NotFound = components?.TweetNotFound || TweetNotFound;
return <NotFound error={onError ? onError(error) : error} />;
}
return <SmoothTweet tweet={data} {...props} />;
};
/* eslint-disable @next/next/no-img-element */
import { cn } from "@/lib/utils";
import { Suspense } from "react";
import { type EnrichedTweet, enrichTweet, type TweetProps } from "react-tweet";
import { getTweet, type Tweet } from "react-tweet/api";
interface IconProps {
className?: string;
[key: string]: unknown;
}
const ExternalLink = ({ className, ...props }: IconProps) => (
<svg
aria-hidden="true"
className={className}
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" x2="21" y1="14" y2="3" />
</svg>
);
export const truncate = (str: string | null, length: number) => {
if (!str || str.length <= length) {
return str;
}
return `${str.slice(0, length - 3)}...`;
};
const Skeleton = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("rounded-md bg-muted", className)} {...props} />
);
export const TweetSkeleton = ({
className,
...props
}: {
className?: string;
[key: string]: unknown;
}) => (
<div
className={cn(
"flex size-full max-h-max min-w-72 flex-col gap-2 rounded-xl border p-4",
className
)}
{...props}
>
<div className="flex flex-row gap-2">
<Skeleton className="size-10 shrink-0 rounded-full" />
<Skeleton className="h-10 w-full" />
</div>
<Skeleton className="h-20 w-full" />
</div>
);
export const TweetNotFound = ({
className,
...props
}: {
className?: string;
[key: string]: unknown;
}) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-2 rounded-lg border p-4",
className
)}
{...props}
>
<h3>Tweet not found</h3>
</div>
);
export const TweetHeader = ({
tweet,
avatarRounded = "rounded",
}: {
tweet: EnrichedTweet;
avatarRounded?: string;
}) => (
<div className="flex items-center gap-2">
<a href={tweet.user.url} rel="noreferrer" target="_blank">
<img
alt={tweet.user.screen_name}
className={cn("size-10 object-cover object-center", avatarRounded)}
draggable={false}
height={40}
loading="eager"
src={tweet.user.profile_image_url_https}
title={`Profile picture of ${tweet.user.name}`}
width={40}
/>
</a>
<div>
<p className="font-semibold text-foreground text-sm tracking-tighter 2xl:text-base">
{tweet.user.name}
</p>
<p className="text-foreground/60 text-xs 2xl:text-sm">
@{truncate(tweet.user.screen_name, 16)}
</p>
</div>
</div>
);
export const TweetBody = ({ tweet }: { tweet: EnrichedTweet }) => (
<blockquote className="flex-1">
<p className="text-balance text-foreground text-sm tracking-tight 2xl:text-base">
{tweet.entities.map((entity, idx) => {
switch (entity.type) {
case "url":
case "symbol":
case "hashtag":
case "mention":
return (
<a
className="ease text-foreground transition-colors duration-200 hover:text-foreground/80"
href={entity.href}
key={`${entity.type}-${idx}-${entity.text.slice(0, 10)}`}
rel="noopener noreferrer"
target="_blank"
>
<span>{entity.text}</span>
</a>
);
case "text":
return (
<span
className="text-foreground"
// biome-ignore lint/suspicious/noArrayIndexKey: Text entities from tweet API have no unique IDs
key={`text-${idx}`}
>
{entity.text}
</span>
);
default:
return null;
}
})}
</p>
</blockquote>
);
export const TweetMedia = ({ tweet }: { tweet: EnrichedTweet }) => {
const hasVideo =
tweet.video &&
Array.isArray(tweet.video.variants) &&
tweet.video.variants.length > 0;
const hasPhotos = tweet.photos && tweet.photos.length > 0;
const hasCardThumbnail =
// @ts-expect-error package doesn't have type definitions
tweet?.card?.binding_values?.thumbnail_image_large?.image_value.url;
if (!(hasVideo || hasPhotos || hasCardThumbnail)) {
return null;
}
return (
<div className="flex w-full flex-col gap-2">
{hasVideo && tweet.video ? (
<video
autoPlay
className="w-full rounded-xl border shadow-sm"
loop
muted
playsInline
poster={tweet.video.poster}
>
<source src={tweet.video.variants[0].src} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : null}
{hasPhotos && tweet.photos
? (() => {
const { photos } = tweet;
return (
<div className="relative grid w-full gap-2">
{photos.length === 1 && (
<img
alt={tweet.text}
className="w-full rounded-xl border object-cover shadow-sm"
draggable={false}
height={photos[0].height}
key={photos[0].url}
src={photos[0].url}
title={`Photo by ${tweet.user.name}`}
width={photos[0].width}
/>
)}
{photos.length === 2 && (
<div className="grid grid-cols-2 gap-2">
{photos.map((photo) => (
<img
alt={tweet.text}
className="w-full rounded-xl border object-cover shadow-sm"
draggable={false}
height={photo.height}
key={photo.url}
src={photo.url}
title={`Photo by ${tweet.user.name}`}
width={photo.width}
/>
))}
</div>
)}
{photos.length >= 3 && (
<div className="grid grid-cols-2 gap-2">
{photos.slice(0, 4).map((photo, index) => (
<img
alt={tweet.text}
className={cn(
"w-full rounded-xl border object-cover shadow-sm",
index === 0 && photos.length > 3 && "row-span-2"
)}
draggable={false}
height={photo.height}
key={photo.url}
src={photo.url}
title={`Photo by ${tweet.user.name}`}
width={photo.width}
/>
))}
</div>
)}
</div>
);
})()
: null}
{!(hasVideo || hasPhotos) && hasCardThumbnail && (
<img
alt={tweet.text}
className="w-full rounded-xl border object-cover shadow-sm"
draggable={false}
src={
// @ts-expect-error package doesn't have type definitions
tweet.card.binding_values.thumbnail_image_large.image_value.url
}
/>
)}
</div>
);
};
export const SmoothTweet = ({
tweet,
className,
userInfoPosition = "bottom",
avatarRounded = "rounded",
...props
}: {
tweet: Tweet;
className?: string;
userInfoPosition?: "top" | "bottom";
avatarRounded?: string;
}) => {
// react-tweet's enrichTweet iterates over each entity array without
// guarding for undefined. The syndication API can omit arrays (e.g.
// symbols/user_mentions) for some tweets, which would throw
// "entities is not iterable" and crash the whole tree. Normalize first.
const safeTweet: Tweet = {
...tweet,
entities: {
hashtags: tweet.entities?.hashtags ?? [],
symbols: tweet.entities?.symbols ?? [],
urls: tweet.entities?.urls ?? [],
user_mentions: tweet.entities?.user_mentions ?? [],
...(tweet.entities?.media ? { media: tweet.entities.media } : {}),
},
};
const enrichedTweet = enrichTweet(safeTweet);
const userInfo = (
<TweetHeader avatarRounded={avatarRounded} tweet={enrichedTweet} />
);
const content = (
<div className="flex w-full flex-col gap-4">
<TweetMedia tweet={enrichedTweet} />
{userInfoPosition === "bottom" && userInfo}
</div>
);
return (
<article
className={cn(
"group !p-6 relative flex flex-col items-start justify-between gap-6 overflow-hidden rounded-xl border bg-background lg:gap-8",
className
)}
data-tweet
{...props}
>
{userInfoPosition === "top" && userInfo}
<TweetBody tweet={enrichedTweet} />
{content}
<button
aria-label="Open tweet in new tab"
className="absolute right-6 bottom-6 z-10 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border bg-primary text-foreground opacity-0 backdrop-blur-sm transition-all duration-200 ease-out group-hover:opacity-100 motion-safe:hover:scale-110"
onClick={() =>
window.open(enrichedTweet.url, "_blank", "noopener,noreferrer")
}
type="button"
>
<ExternalLink className="h-4 w-4" />
</button>
</article>
);
};
/**
* TweetCard (Server Side Only)
*/
export type TweetCardProps = TweetProps & {
className?: string;
userInfoPosition?: "top" | "bottom";
avatarRounded?: string;
};
export const TweetCard = async ({
id,
components,
fallback = <TweetSkeleton />,
onError,
...props
}: TweetCardProps) => {
const tweet = id
? await getTweet(id).catch((err) => {
if (onError) {
onError(err);
} else {
console.error(err);
}
})
: undefined;
if (!tweet) {
const NotFound = components?.TweetNotFound || TweetNotFound;
return <NotFound {...props} />;
}
return (
<Suspense fallback={fallback}>
<SmoothTweet tweet={tweet} {...props} />
</Suspense>
);
};
export { ClientTweetCard } from "./client";
export default TweetCard;