Embed
Embed Card
URL embed cards for YouTube, X, Instagram, Reddit, Maps, Vimeo, TikTok, and a link-preview fallback.
components/embed-card
Source
Copy the files below into your project.
apps/web/components/landing/showcases/embed-card-demo.tsx
'use client'
import { useEffect, useState, type CSSProperties } from 'react'
import { ThemedEmbedCard } from 'embed-card/next-themes'
import { cn } from '@tawny/ui/lib/utils'
const SAMPLES = [
{
id: 'youtube',
label: 'YouTube',
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
},
{
id: 'x',
label: 'X',
url: 'https://x.com/1chooo___/status/2028573993972969585',
},
{
id: 'instagram',
label: 'Instagram',
url: 'https://www.instagram.com/p/DXKYttciGe7/',
},
{
id: 'reddit',
label: 'Reddit',
url: 'https://www.reddit.com/r/github/comments/1j6jga7/i_rebuilt_my_personal_portfolio_using_nextjsits/',
},
{
id: 'maps',
label: 'Maps',
url: 'https://www.google.com/maps?q=Tokyo+Station&output=embed',
},
{
id: 'vimeo',
label: 'Vimeo',
url: 'https://vimeo.com/76979871',
},
{
id: 'tiktok',
label: 'TikTok',
url: 'https://www.tiktok.com/@scout2015/video/6718335390845095173',
},
{
id: 'link',
label: 'Link',
url: 'https://1chooo.com',
},
] as const
type SampleId = (typeof SAMPLES)[number]['id']
/** Provider-specific stage so 9:16 / 1:1 embeds do not blow out the playground. */
const PREVIEW_STYLE: Record<SampleId, CSSProperties> = {
youtube: { width: '100%' },
vimeo: { width: '100%' },
maps: { width: '100%' },
x: { width: '100%', maxWidth: 440, marginInline: 'auto', maxHeight: 520 },
reddit: { width: '100%', maxWidth: 560, marginInline: 'auto' },
link: { width: '100%', maxWidth: 560, marginInline: 'auto' },
instagram: {
width: '100%',
maxWidth: 328,
marginInline: 'auto',
minHeight: 0,
maxHeight: 520,
},
tiktok: {
width: '100%',
maxWidth: 328,
marginInline: 'auto',
minHeight: 0,
maxHeight: 'min(420px, 50dvh)',
},
}
/**
* Mount after hydration. next-themes + third-party iframes mismatch SSR HTML
* (Tawny defaults to dark; ThemedEmbedCard's first paint is light).
*/
function ClientThemedEmbedCard({
url,
style,
}: {
url: string
style?: CSSProperties
}) {
const [ready, setReady] = useState(false)
useEffect(() => {
setReady(true)
}, [])
if (!ready) {
return (
<div
aria-hidden
className="w-full rounded-xl bg-muted/50"
style={{ aspectRatio: '16 / 9', minHeight: 180 }}
/>
)
}
return (
<ThemedEmbedCard
key={url}
url={url}
ctaLabel="Visit site"
style={style}
/>
)
}
/**
* 16:9 gallery / changelog crop — YouTube only, no scaled canvas.
* Live iframes must not be CSS-scaled; tall providers would clip here.
*/
export function EmbedCardGalleryPreview({ className }: { className?: string }) {
return (
<div className={cn('h-full overflow-hidden bg-background', className)}>
<ClientThemedEmbedCard url={SAMPLES[0].url} />
</div>
)
}
/**
* Detail-page playground. Natural height (not a scaled 16:9 canvas) so each
* provider can keep its own aspect ratio.
*/
export function EmbedCardDemo({ className }: { className?: string }) {
const [url, setUrl] = useState<string>(SAMPLES[0].url)
const sample = SAMPLES.find((item) => item.url === url) ?? SAMPLES[0]
return (
<div className={cn('flex flex-col bg-background', className)}>
<div className="flex flex-wrap gap-1.5 border-b border-border px-4 py-3">
{SAMPLES.map((item) => {
const selected = item.url === url
return (
<button
key={item.id}
type="button"
onClick={() => setUrl(item.url)}
className={cn(
'rounded-full border px-3 py-1 text-xs transition-colors',
selected
? 'border-tawny/40 bg-tawny/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:border-tawny/25 hover:text-foreground',
)}
>
{item.label}
</button>
)
})}
</div>
<div className="flex justify-center px-4 py-6">
<div className="w-full">
<ClientThemedEmbedCard
url={sample.url}
style={PREVIEW_STYLE[sample.id]}
/>
</div>
</div>
</div>
)
}