Custom Rendering
A real-world case study showing how to integrate embed-card into a project with its own design system — keeping full styling control while reusing the package's data layer.
Custom Rendering
This page walks through how 1chooo.com integrated embed-card into an existing Next.js site that uses a bespoke Tailwind design system (rurikon-* color tokens, font-serif typography). The result is a set of components that feel native to the site while removing ~100 lines of duplicated data-fetching logic.
The patterns here apply to any project that:
- Has an established design language that should not be overridden by the default card styles
- Needs a Reddit thread preview but wants to keep custom Tailwind classes
- Needs a Twitter/X post card using
react-tweet(native server-side rendering)
When to use EmbedCard directly
Use <EmbedCard url="..." /> when you want the embed surface out of the box and are happy to theme it via the theme prop:
<EmbedCard
url="https://www.reddit.com/r/nextjs/comments/..."
theme={{
accentColor: "#ff4500",
radius: 32,
fontFamily: "Georgia, 'Times New Roman', serif",
}}
/>Live: default vs customized
Same Reddit thread URL in both columns — only the theme prop changes. Scroll horizontally on small viewports if the cards stack.
Default
`EmbedCard` with no `theme` prop — package defaults, system font.
Themed
Same URL with `theme`: warm accent, tighter radius, and serif `fontFamily`.
When NOT to use EmbedCard directly
Some components are better left as fully custom renders. A good example is a Tweet card built with react-tweet.
Twitter / X — native rendering with react-tweet
embed-card handles X/Twitter via an <iframe> (the standard Twitter embed widget). If you want a server-rendered, visually native card that matches your site's typography and color system, react-tweet is a better fit. The two are complementary, not competing.
// components/tweet-card.tsx — server component (no embed-card needed here)
import { Suspense } from "react"
import { enrichTweet } from "react-tweet"
import { getTweet } from "react-tweet/api"
export async function TweetCard({ id }: { id: string }) {
const tweet = await getTweet(id).catch(() => undefined)
if (!tweet) return <TweetNotFound />
return (
<Suspense
fallback={
<div className="my-8 border border-rurikon-border bg-[#fefefe] h-40 animate-pulse font-serif" />
}
>
<MagicTweet tweet={tweet} />
</Suspense>
)
}
function MagicTweet({ tweet }: { tweet: ReturnType<typeof getTweet> extends Promise<infer T> ? T : never }) {
const enriched = enrichTweet(tweet!)
return (
<div className="my-8 border border-rurikon-border bg-[#fefefe] px-6 py-6 font-serif sm:px-8">
{/* header, body, media, date — fully controlled */}
</div>
)
}Key point: Keep TweetCard as a custom component. Add react-tweet to your project:
pnpm add react-tweetReddit — data-layer reuse, custom rendering
The 1chooo.com RedditEmbed component originally duplicated every data helper from embed-card:
fetchRedditPost, formatScore, timeAgo, RedditPost, RedditVideo. These are all
already exported from embed-card, so the migration simply replaces the local definitions
with package imports.
Before (duplicated local code)
// Local types and helpers — ~50 lines of duplication
interface RedditVideo { fallback_url: string; ... }
interface RedditPost { title: string; score: number; ... }
async function fetchRedditPost(url: string): Promise<RedditPost | null> {
const res = await fetch(`${url.replace(/\/$/, "")}.json?limit=1`)
...
}
function formatScore(n: number) { ... }
function timeAgo(utc: number) { ... }After (import from embed-card)
pnpm add embed-card"use client"
import {
fetchRedditPost,
formatRedditScore,
redditTimeAgo,
decodeRedditHtmlEntities,
} from "embed-card"
import type { RedditPostData } from "embed-card"The rest of the component — all the Tailwind classes, rurikon-* color tokens, Lucide icons,
font-serif, and the custom card layout — stays exactly the same. Only the data layer changes.
Full migrated component
"use client"
import { ArrowUp, MessageSquare } from "lucide-react"
import { useState, useEffect } from "react"
import {
fetchRedditPost,
formatRedditScore,
redditTimeAgo,
decodeRedditHtmlEntities,
} from "embed-card"
import type { RedditPostData } from "embed-card"
import { RedditCopyLinkButton } from "@/components/reddit-copy-link-button"
interface RedditEmbedProps {
url: string
}
export function RedditEmbed({ url }: RedditEmbedProps) {
const [post, setPost] = useState<RedditPostData | null | "loading">("loading")
useEffect(() => {
const ac = new AbortController()
fetchRedditPost(url, { signal: ac.signal }).then((result) => {
if (!ac.signal.aborted) setPost(result)
})
return () => ac.abort()
}, [url])
if (post === "loading") {
return (
<div className="my-8 border border-rurikon-border bg-[#fefefe] px-6 py-8 font-serif animate-pulse">
<div className="h-3 w-1/3 rounded bg-rurikon-border/60 mb-3" />
<div className="h-4 w-3/4 rounded bg-rurikon-border/60 mb-2" />
<div className="h-4 w-1/2 rounded bg-rurikon-border/40" />
</div>
)
}
if (!post) {
return (
<div className="my-8 border border-rurikon-border bg-[#fefefe] px-6 py-8 font-serif">
<p className="text-center text-sm text-rurikon-300">Post unavailable.</p>
</div>
)
}
const videoUrl = post.is_video
? decodeRedditHtmlEntities(post.media?.reddit_video?.fallback_url ?? "") || null
: null
const posterUrl = post.preview?.images?.[0]?.source?.url
? decodeRedditHtmlEntities(post.preview.images[0].source.url)
: undefined
const body =
post.is_self && post.selftext
? post.selftext.length > 280
? post.selftext.slice(0, 280).trimEnd() + "…"
: post.selftext
: null
const postHref = `https://www.reddit.com${post.permalink}`
return (
<div className="my-8 border border-rurikon-border bg-[#fefefe] font-serif">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-rurikon-border/40">
<div className="flex flex-col gap-0.5">
<a
href={`https://www.reddit.com/r/${post.subreddit}`}
target="_blank" rel="noreferrer"
className="text-xs font-semibold text-rurikon-500 hover:opacity-70 transition-opacity"
>
r/{post.subreddit}
</a>
<span className="text-[11px] text-rurikon-300">
Posted by u/{post.author} · {redditTimeAgo(post.created_utc)}
</span>
</div>
</div>
{/* Title + body */}
<div className="px-5 pt-4 pb-3">
<a href={postHref} target="_blank" rel="noreferrer"
className="text-sm font-medium text-rurikon-600 leading-snug hover:opacity-70 transition-opacity">
{post.title}
</a>
{body && (
<p className="mt-2 text-xs leading-relaxed text-rurikon-400 whitespace-pre-line">{body}</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-y-2 gap-x-3 px-5 py-3 flex-wrap">
<div className="flex items-center gap-4 flex-wrap">
<span className="flex items-center gap-1 text-xs text-rurikon-300">
<ArrowUp className="size-3.5" />
{formatRedditScore(post.score)}
</span>
<a href={postHref} target="_blank" rel="noreferrer"
className="flex items-center gap-1 text-xs text-rurikon-300 hover:text-rurikon-500 transition-colors">
<MessageSquare className="size-3.5" />
{post.num_comments.toLocaleString()} comments
</a>
<RedditCopyLinkButton href={postHref} />
</div>
</div>
</div>
)
}What changed
| Before | After |
|---|---|
Local interface RedditPost | RedditPostData from embed-card |
Local interface RedditVideo | RedditVideo from embed-card |
Local fetchRedditPost | fetchRedditPost from embed-card (adds AbortController support) |
Local formatScore | formatRedditScore from embed-card |
Local timeAgo | redditTimeAgo from embed-card |
Manual & replace | decodeRedditHtmlEntities from embed-card |
| No cleanup on URL change | AbortController cleanup via useEffect return |
Reddit copy-link button — build your own
embed-card now exports a RedditCopyLinkButton component (inline-style, no Tailwind dependency):
import { RedditCopyLinkButton } from "embed-card"
<RedditCopyLinkButton href={postHref} />If your project uses Tailwind and Lucide, you may prefer to keep a custom version that matches your design system — exactly as 1chooo.com does:
// components/reddit-copy-link-button.tsx
"use client"
import { Link, Check } from "lucide-react"
import { useState } from "react"
export function RedditCopyLinkButton({ href }: { href: string }) {
const [copied, setCopied] = useState(false)
function handleCopy() {
navigator.clipboard.writeText(href).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<button
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-rurikon-300 hover:text-rurikon-500 transition-colors cursor-pointer"
>
{copied ? (
<><Check className="size-3.5" />Copied!</>
) : (
<><Link className="size-3.5" />Copy link</>
)}
</button>
)
}Both versions have identical logic — the choice is purely cosmetic.
Using RedditEmbedPreview with style overrides
If you want to use the package's RedditEmbedPreview component but need to apply project-specific
styles, pass className and/or style:
Live: default variables vs editorial wrapper
Left: RedditEmbedPreview with createThemeVariables() defaults. Right: editorial wrapper with a warm accent, serif font, tighter radius, and CSS variable overrides for finer surface control.
Default
`RedditEmbedPreview` with `createThemeVariables()` defaults.
Custom theme
Editorial `createThemeVariables({ ... })` on the wrapper, plus `className` and `style` on the preview.
import { RedditEmbedPreview } from "embed-card"
<RedditEmbedPreview
postUrl="https://www.reddit.com/r/github/comments/1j6jga7/..."
className="my-8 font-serif"
style={{
"--embed-card-border": "rgba(180, 160, 120, 0.25)",
"--embed-card-font-family": "Georgia, 'Times New Roman', serif",
} as React.CSSProperties}
/>CSS variables set on the wrapper are inherited by all embed-card subcomponents. See Theming reference for the full variable list.
Dark mode and appearance
Pass appearance to control whether the embed surface (gradients, borders, background) uses a light or dark palette.
| Value | Behavior |
|---|---|
"light" (default) | Always use the light palette. |
"dark" | Always use the dark palette. |
"system" | Follow prefers-color-scheme at runtime; falls back to light on SSR. |
<EmbedCard
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
theme={{ appearance: "dark", accentColor: "#7c3aed" }}
/>Recommended: ThemedEmbedCard with next-themes
When the site already uses next-themes, prefer the dedicated export. It maps resolvedTheme (and the dark class on html when attribute: "class") onto theme.appearance, which avoids a flash or hydration mismatch compared to hand-rolled useTheme() logic in some layouts.
pnpm add next-themesimport { ThemedEmbedCard } from "embed-card/next-themes"
export function ArticleEmbed() {
return (
<ThemedEmbedCard
url="https://vimeo.com/76979871"
theme={{ accentColor: "#7c3aed" }}
/>
)
}An explicit theme.appearance on the component still wins, so you can force light or dark for a single embed when needed.
Manual wiring with useTheme()
If you cannot import from embed-card/next-themes, read resolvedTheme from next-themes and pass theme.appearance yourself:
"use client"
import { useTheme } from "next-themes"
import { EmbedCard } from "embed-card"
import type { EmbedCardTheme } from "embed-card"
export function ThemedEmbed() {
const { resolvedTheme } = useTheme()
const appearance: EmbedCardTheme["appearance"] =
resolvedTheme === "dark" ? "dark" : "light"
return (
<EmbedCard
url="https://vimeo.com/76979871"
theme={{ appearance, accentColor: "#7c3aed" }}
/>
)
}Theming reference
EmbedCardTheme exposes five fields. Everything else is derived automatically from accentColor and appearance.
EmbedCardTheme field | CSS variable | Default |
|---|---|---|
accentColor | --embed-card-accent | Provider's own color |
radius | --embed-card-radius | 24px |
shadow | --embed-card-shadow | none |
fontFamily | --embed-card-font-family | system-ui stack |
appearance | (sets palette) | "light" |
Advanced: CSS variable overrides
The following variables are set automatically but can be overridden on a wrapper element for fully custom rendering scenarios:
| CSS variable | Light default | Dark default |
|---|---|---|
--embed-card-border | derived from accentColor | derived from accentColor |
--embed-card-background | rgba(255,255,255,0.98) | rgba(15,23,42,0.97) |
--embed-card-text | #0f172a | #f1f5f9 |
--embed-card-muted | rgba(15,23,42,0.62) | rgba(226,232,240,0.55) |
--embed-card-chrome-tint | #ffffff | #0f172a |
--embed-card-preview-canvas | #ffffff | #0d1420 |
--embed-card-chrome-tint is the color mixed into the surface gradient and border blend. --embed-card-preview-canvas is the base fill behind the iframe or preview panel. Set them on a wrapper element when building a fully custom render:
<div style={{
"--embed-card-font-family": "'Lora', serif",
"--embed-card-chrome-tint": "#f8f5f0",
"--embed-card-preview-canvas": "#fefcf8",
} as React.CSSProperties}>
<RedditEmbedPreview postUrl="..." />
</div>