Skip to content
Corsair UI
Browse the docs

ComponentsNo. 63 of 109

Tweet Card

A post from X in your theme, with no embed script: a card, a server component that fetches by id, and a client one.

Installation

pnpm dlx shadcn@latest add @corsair-ui/tweet-card

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utils
npm
clsxlucide-reactreact-tweettailwind-merge

Examples

Fetched by id

Tweet, a server component, fetched this post when the page was built.

Loading

API

PropTypeDefault
tweet

TweetCard: the post, from getTweet, useTweet or your own fetch.

Tweet—
id

Tweet (server) and ClientTweet (browser): fetch by id.

string—
showDate / showLikes / showCopyLink

Parts to show.

booleantrue
locale / timeZone

For the date.

string"en-US" / "UTC"

Accessibility

  • It is an article named after the author; the text is selectable and its links are real links.
  • The text is decoded as text, never injected as HTML.
  • Photos carry their alt text; videos have controls instead of autoplaying.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")

Source

2 files, installed as source you own
tweet-card.tsxView on GitHub
"use client";

import { BadgeCheckIcon, CheckIcon, HeartIcon, LinkIcon } from "lucide-react";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { enrichTweet, useTweet, type EnrichedTweet } from "react-tweet";
import type { Tweet, TweetEntities } from "react-tweet/api";

import { cn } from "@/registry/default/lib/utils";

// The X logo.
const X_LOGO =
  "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z";

const ENTITIES: Record<string, string> = {
  amp: "&",
  lt: "<",
  gt: ">",
  quot: '"',
  apos: "'",
  nbsp: "\u00a0",
};

/** X sends tweet text HTML-escaped. Decode it as text, never as markup. */
function decode(text: string) {
  return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
    if (entity[0] === "#") {
      const code =
        entity[1] === "x" || entity[1] === "X"
          ? parseInt(entity.slice(2), 16)
          : parseInt(entity.slice(1), 10);
      return Number.isFinite(code) && code > 0 && code < 0x110000
        ? String.fromCodePoint(code)
        : match;
    }
    return ENTITIES[entity.toLowerCase()] ?? match;
  });
}

function compact(count: number) {
  return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(
    count
  );
}

/**
 * The syndication API leaves out empty entity lists, and older versions of
 * the enricher assumed they were there. Fill them in, and fall back to
 * nothing rather than throwing on a tweet shape nobody expected.
 */
const ENTITY_LISTS = ["hashtags", "urls", "user_mentions", "symbols"] as const;

function enrich(tweet: Tweet): EnrichedTweet | null {
  const entities: Partial<TweetEntities> = { ...tweet.entities };
  for (const list of ENTITY_LISTS) entities[list] ??= [];
  try {
    return enrichTweet({ ...tweet, entities: entities as TweetEntities });
  } catch {
    return null;
  }
}

const frame = "w-full max-w-xl rounded-xl border bg-card p-4 text-card-foreground shadow";

/** The card's size and shape while a tweet loads. */
function TweetCardSkeleton({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      role="status"
      aria-label="Loading post"
      data-slot="tweet-card-skeleton"
      className={cn(frame, className)}
      {...props}
    >
      <div className="flex items-center gap-2.5">
        <div className="bg-muted size-10 shrink-0 animate-pulse rounded-full" />
        <div className="grid gap-1.5">
          <div className="bg-muted h-3.5 w-28 animate-pulse rounded" />
          <div className="bg-muted h-3 w-20 animate-pulse rounded" />
        </div>
      </div>
      <div className="mt-4 grid gap-2">
        <div className="bg-muted h-3.5 w-full animate-pulse rounded" />
        <div className="bg-muted h-3.5 w-3/4 animate-pulse rounded" />
      </div>
    </div>
  );
}

/** What shows when a tweet is deleted, private or could not be fetched. */
function TweetCardUnavailable({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="tweet-card-unavailable"
      className={cn(frame, "text-muted-foreground py-6 text-center text-sm", className)}
      {...props}
    >
      This post is unavailable.
    </div>
  );
}

function CopyLink({ url }: { url: string }) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(url);
    } catch {
      return;
    }
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1600);
  };

  return (
    <button
      type="button"
      onClick={copy}
      className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex cursor-pointer items-center gap-1.5 rounded-md transition-colors outline-none focus-visible:ring-[3px]"
    >
      {copied ? (
        <CheckIcon aria-hidden="true" className="text-success size-4" />
      ) : (
        <LinkIcon aria-hidden="true" className="size-4" />
      )}
      <span aria-live="polite">{copied ? "Copied" : "Copy link"}</span>
    </button>
  );
}

interface TweetCardProps extends Omit<ComponentProps<"article">, "children"> {
  /** The tweet as the syndication API returns it: from `getTweet`, `useTweet` or your own fetch. */
  tweet: Tweet;
  showDate?: boolean;
  showLikes?: boolean;
  showCopyLink?: boolean;
  /** Locale for the date. A fixed default keeps the server and the browser in agreement. */
  locale?: string;
  /** Time zone for the date. */
  timeZone?: string;
}

/**
 * A post from X, drawn with your theme instead of an embed: no iframe, no
 * third-party script, and text you can select. Mentions, hashtags and links
 * are real links; the text is decoded as text, never injected as HTML.
 * Photos carry their alt text and videos have controls rather than
 * autoplaying. For server components, `Tweet` fetches by id and renders
 * this card.
 *
 * @example
 * const { data } = useTweet(id);
 * return data ? <TweetCard tweet={data} /> : <TweetCardSkeleton />;
 */
function TweetCard({
  tweet: raw,
  showDate = true,
  showLikes = true,
  showCopyLink = true,
  locale = "en-US",
  timeZone = "UTC",
  className,
  ...props
}: TweetCardProps) {
  const tweet = enrich(raw);
  if (!tweet) return <TweetCardUnavailable className={className} />;

  const date = new Date(tweet.created_at);
  const photos = tweet.mediaDetails?.filter((media) => media.type === "photo") ?? [];
  const video = tweet.video;
  const source =
    video?.variants.find((variant) => variant.type === "video/mp4") ?? video?.variants[0];

  return (
    <article
      data-slot="tweet-card"
      aria-label={`Post by ${tweet.user.name}`}
      className={cn(frame, className)}
      {...props}
    >
      <header className="flex items-start justify-between gap-3">
        <a
          href={tweet.user.url}
          target="_blank"
          rel="noopener noreferrer"
          className="focus-visible:ring-ring/50 flex min-w-0 items-center gap-2.5 rounded-md outline-none focus-visible:ring-[3px]"
        >
          <img
            src={tweet.user.profile_image_url_https}
            alt=""
            width={40}
            height={40}
            loading="lazy"
            className={cn(
              "size-10 shrink-0 object-cover",
              tweet.user.profile_image_shape === "Square" ? "rounded-md" : "rounded-full"
            )}
          />
          <span className="grid min-w-0">
            <span className="flex items-center gap-1 truncate text-sm font-semibold">
              {tweet.user.name}
              {tweet.user.verified || tweet.user.is_blue_verified ? (
                <BadgeCheckIcon aria-label="Verified" className="size-4 shrink-0 text-sky-500" />
              ) : null}
            </span>
            <span className="text-muted-foreground truncate text-sm">
              @{tweet.user.screen_name}
            </span>
          </span>
        </a>
        <a
          href={tweet.url}
          target="_blank"
          rel="noopener noreferrer"
          aria-label="View on X"
          className="text-foreground focus-visible:ring-ring/50 shrink-0 rounded-md outline-none hover:opacity-70 focus-visible:ring-[3px]"
        >
          <svg viewBox="0 0 24 24" aria-hidden="true" className="size-4 fill-current">
            <path d={X_LOGO} />
          </svg>
        </a>
      </header>

      <p className="mt-3 text-[15px] leading-6 break-words whitespace-pre-wrap">
        {tweet.entities.map((entity, index) =>
          entity.type === "text" ? (
            <span key={index}>{decode(entity.text)}</span>
          ) : entity.type === "media" ? null : (
            <a
              key={index}
              href={entity.href}
              target="_blank"
              rel="noopener noreferrer"
              className="text-sky-600 hover:underline dark:text-sky-400"
            >
              {decode(entity.text)}
            </a>
          )
        )}
      </p>

      {photos.length > 0 ? (
        <div
          className={cn(
            "mt-3 grid gap-1 overflow-hidden rounded-lg",
            photos.length > 1 && "grid-cols-2"
          )}
        >
          {photos.map((photo, index) => (
            <img
              key={photo.media_url_https}
              src={photo.media_url_https}
              alt={photo.type === "photo" ? (photo.ext_alt_text ?? "") : ""}
              width={photo.original_info.width}
              height={photo.original_info.height}
              loading="lazy"
              className={cn(
                "size-full object-cover",
                photos.length === 3 && index === 0 && "row-span-2"
              )}
            />
          ))}
        </div>
      ) : null}

      {video && source ? (
        <video
          poster={video.poster}
          controls
          muted
          playsInline
          preload="metadata"
          className="mt-3 w-full rounded-lg"
        >
          <source src={source.src} type={source.type} />
        </video>
      ) : null}

      {showDate ? (
        <time dateTime={tweet.created_at} className="text-muted-foreground mt-3 block text-sm">
          {new Intl.DateTimeFormat(locale, {
            hour: "numeric",
            minute: "2-digit",
            month: "short",
            day: "numeric",
            year: "numeric",
            timeZone,
          }).format(date)}
        </time>
      ) : null}

      {showLikes || showCopyLink ? (
        <footer className="mt-3 flex items-center gap-5 border-t pt-3 text-sm">
          {showLikes ? (
            <a
              href={tweet.like_url}
              target="_blank"
              rel="noopener noreferrer"
              className="group/like text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md outline-none focus-visible:ring-[3px]"
            >
              <HeartIcon
                aria-hidden="true"
                className="size-4 transition-colors group-hover/like:fill-rose-500 group-hover/like:text-rose-500"
              />
              <span>
                {compact(tweet.favorite_count)}
                <span className="sr-only"> likes, like on X</span>
              </span>
            </a>
          ) : null}
          {showCopyLink ? <CopyLink url={tweet.url} /> : null}
        </footer>
      ) : null}
    </article>
  );
}

/**
 * Fetches a tweet in the browser and renders it. It uses react-tweet's
 * `useTweet`, which by default asks react-tweet's public endpoint; pass
 * `apiUrl` to go through your own route instead.
 */
function ClientTweet({
  id,
  apiUrl,
  ...props
}: Omit<TweetCardProps, "tweet"> & { id: string; apiUrl?: string }) {
  const { data, isLoading } = useTweet(id, apiUrl);
  if (isLoading) return <TweetCardSkeleton className={props.className} />;
  if (!data) return <TweetCardUnavailable className={props.className} />;
  return <TweetCard tweet={data} {...props} />;
}

export { ClientTweet, TweetCard, TweetCardSkeleton, TweetCardUnavailable, type TweetCardProps };
tweet.tsxView on GitHub
import { getTweet } from "react-tweet/api";

import {
  TweetCard,
  TweetCardUnavailable,
  type TweetCardProps,
} from "@/registry/default/ui/tweet-card";

interface TweetProps extends Omit<TweetCardProps, "tweet"> {
  /** The id at the end of the post's URL. */
  id: string;
  /** Passed to `fetch`, e.g. `{ next: { revalidate: 3600 } }` to cache it in Next.js. */
  fetchOptions?: RequestInit;
}

/**
 * A server component that fetches a post from X's syndication API while
 * the page renders and shows it with TweetCard: nothing is fetched in the
 * browser and no third-party script loads. Wrap it in `<Suspense>` with a
 * `TweetCardSkeleton` fallback to stream the rest of the page first. In
 * client components, use `ClientTweet` instead.
 *
 * @example
 * <Suspense fallback={<TweetCardSkeleton />}>
 *   <Tweet id="1628832338187636740" />
 * </Suspense>
 */
async function Tweet({ id, fetchOptions, ...props }: TweetProps) {
  const tweet = await getTweet(id, fetchOptions).catch(() => undefined);
  if (!tweet) return <TweetCardUnavailable className={props.className} />;
  return <TweetCard tweet={tweet} {...props} />;
}

export { Tweet, type TweetProps };