← all demos

social-post

A platform-neutral social post embed: quote a post from X, Bluesky, Mastodon, LinkedIn, or anywhere else, and it renders as this one uniform, themeable card — no brand chrome, no fetching, every field passed in as data. Mentions, hashtags, and URLs in the content are tinted but deliberately inert; the Source ↗ link in the footer — under a full-bleed hairline — is the card's only interactive element, so the quoted text stays selectable. Media (1–4 images) is cover-cropped into one fixed 16:9 frame so cards keep identical proportions. Comes as an outlined card or a filled gray one.

Install: npx shadcn@latest add @lloydhumphreys/social-post

Images
Avatar
Verified
Date
Variant
Content
Theme

Broken URL points the avatar at a 404 — the neutral silhouette holds, because the image is only revealed on a real load event (a dead URL never flashes a broken-image glyph). The Long content ends its URL with a period: the period stays plain text, outside the tinted span. Tab through the card — the footer link is the only stop — and click-drag the text: nothing intercepts selection.

API

The vanilla core (social-post.ts, zero dependencies). The React wrapper (social-post-react.tsx) exposes the same options as props; social-post-shadcn is a separate registry item with the card rebuilt from Tailwind tokens, your app's <Avatar>, and lucide icons — self-contained in one file.

import { createSocialPost } from './social-post'

const post = createSocialPost({
  name: 'Ada Lovelace',
  handle: 'adalovelace',          // '@' is prepended for you
  verified: true,
  content: 'Notes are up — thanks @babbage! https://example.com/notes #computing',
  avatarUrl: 'https://…/ada.jpg', // optional; silhouette fallback if missing or dead
  images: ['https://…/1.jpg', 'https://…/2.jpg'],   // 0–4, fixed 16:9 frame
  date: 'Mar 3, 2026',            // optional, preformatted — never parsed
  link: 'https://example.com/adalovelace/status/1',
})
host.appendChild(post.element)

post.setState({ verified: false })   // patch any field live

createSocialPost(options) → SocialPost

OptionType
namestring Display name.
handlestring Bare handle — the card prepends the @ (a passed @ is forgiven and stripped).
contentstring The post text. Line breaks preserved; always rendered as text, never markup. Mentions, hashtags, and http(s) URLs are tinted as inert spans.
avatarUrlstring? Omitted — or dead (load error) — the neutral silhouette shows instead. The image is only revealed once it actually loads.
imagesstring[]? 0–4 image URLs. 1 fills the frame, 2 side by side, 3 one-tall-plus-two, 4 in a 2×2 — all cover-cropped inside one fixed 16:9 frame. More than 4 are truncated with a console.warn.
linkstring Href of the original post — the footer's "Source ↗", the card's only link (opens in a new tab).
datestring? Preformatted display string ('4:20 PM · Mar 3, 2026', 'Mar 2026'…). Rendered verbatim — never parsed, which is also why it's a plain span rather than a <time datetime> with nothing honest to put in the attribute.
verifiedboolean = false Check badge after the name. Neutral-colored on purpose — the shape says "verified", the color stays --foreground so it reads as no platform's brand check.
variant'outline' | 'filled' Default 'outline' — a bordered card on the card surface. 'filled' keeps the same geometry on a borderless muted-gray fill (the border goes transparent, not away, so nothing shifts by a pixel when variants mix).
labels{ root?, source?, verified? } Accessible name of the card, footer link text (default "Source"), badge label.
injectStylesboolean = true Auto-inject CSS; set false and ship socialPostStyles() yourself.
classNamestring Extra class(es) on the root.

Instance: element, getState(), setState(patch), destroy(). setState applies the keys present in the patch (key: undefined clears an optional field); the avatar <img> and the media grid are only rebuilt when their values actually changed, so toggling verified never re-fetches images. There's deliberately no engine/subscribe layer — the card has no navigation or timing state, only display data.

Customizing

Two layers: the variant prop picks the built-in look, and the --social-post-* CSS variables override any single part — they win over both variants, from the card itself or any ancestor.

// The built-in filled gray card — borderless, muted fill
createSocialPost({ ...post, variant: 'filled' })   // vanilla
<SocialPost variant="filled" … />                  // react wrapper and shadcn-native

/* Any custom background (or accent, radius, …) via the escape hatches.
   Scope them to a wrapper — or set them globally — and every card follows: */
.press-quotes {
  --social-post-bg: #f4f4f5;        /* the fill (dark mode: use light-dark()) */
  --social-post-accent: #0e7490;    /* mention/hashtag/URL tint */
  --social-post-radius: 1rem;       /* corners */
}

/* shadcn-native only: className merges last, so call-site utilities work too */
<SocialPost className="border-transparent bg-muted" … />

Entities

Detection is purely lexical, and honest about its limits: only explicit http(s):// URLs (no bare domains or www.); mentions accept the fediverse form @user@instance.tld; hashtags accept unicode (#café, #日本語). Sentence punctuation trailing a URL is trimmed — including an unbalanced closing paren, while wiki_(disambiguation) keeps its own. Entities glued together without whitespace (@jane#tag) stay merged. Everything renders via textContent — content is never interpreted as HTML. The split function is exported (splitContentEntities) if you want to render segments yourself.

Theming

Consumes shadcn theme tokens when present (--card, --card-foreground, --border, --foreground, --muted, --muted-foreground, --primary, --ring, --radius) with zinc light-dark() fallbacks — standalone it still reads correctly in both themes. Override independently via --social-post-bg/-fg, --social-post-border, --social-post-radius, --social-post-name, --social-post-handle, --social-post-verified, --social-post-avatar-bg/-fg, --social-post-accent (the entity tint — defaults to --primary, so it tracks your app's emphasis color; entities also carry a slight weight bump so they still read in themes where primary sits near foreground), --social-post-media-radius, --social-post-media-gap, --social-post-date, --social-post-link/-hover, --social-post-ring. The filled variant swaps the default fill to --muted and turns the border transparent — --social-post-bg overrides the fill for either variant.