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
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.
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| Option | Type | |
|---|---|---|
name | string | Display name. |
handle | string | Bare handle — the card prepends the @ (a passed @
is forgiven and stripped). |
content | string | The post text. Line breaks preserved; always rendered as text, never markup. Mentions, hashtags, and http(s) URLs are tinted as inert spans. |
avatarUrl | string? | Omitted — or dead (load error) — the neutral silhouette shows instead. The image is only revealed once it actually loads. |
images | string[]? | 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. |
link | string | Href of the original post — the footer's "Source ↗", the card's only link (opens in a new tab). |
date | string? | 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. |
verified | boolean = 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. |
injectStyles | boolean = true | Auto-inject CSS; set false and ship socialPostStyles()
yourself. |
className | string | 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.
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" … />
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.
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.