Components
Phone Card
A beautiful phone mockup card component with lazy-loading video support.
Last updated on
import { PhoneCard } from "@/components/ui/phone-card";
export function PhoneCardDemo() {
return (
<div className="flex min-h-screen items-center justify-center p-8">
<PhoneCard
title="8°"
sub="Clear night. Great for render farm runs."
tone="calm"
gradient="from-[#0f172a] via-[#14532d] to-[#052e16]"
videoSrc="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/A%20new%20chapter%20in%20the%20story%20of%20success.__Introducing%20the%20new%20TAG%20Heuer%20Carrera%20Day-Date%20collection%2C%20reimagined%20with%20bold%20colors%2C%20refined%20finishes%2C%20and%20upgraded%20functionality%20to%20keep%20you%20focused%20on%20your%20goals.%20__Six%20-nDNoRQyFaZ8oaaoty4XaQz8W8E5bqA.mp4"
mediaType="video"
/>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/phone-card"Manual
Copy and paste the following code into your project component/ui/lazy-video.tsx
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
export interface LazyVideoProps
extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
fallback?: string;
threshold?: number;
rootMargin?: string;
}
export function LazyVideo({
src,
className = "",
poster,
autoPlay = false,
loop = false,
muted = true,
controls = false,
playsInline = true,
"aria-label": ariaLabel,
...props
}: LazyVideoProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const el = videoRef.current;
if (!el) return;
const prefersReducedMotion =
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
const saveData =
(navigator as unknown as { connection?: { saveData?: boolean } })
?.connection?.saveData === true;
const shouldAutoplay = autoPlay && !prefersReducedMotion && !saveData;
let observer: IntersectionObserver | null = null;
const onIntersect: IntersectionObserverCallback = (entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting && !loaded) {
el.src = src;
el.load();
if (shouldAutoplay) {
const playVideo = async () => {
try {
await el.play();
} catch (_error) {
// Autoplay might be blocked
// console.log("[v0] Autoplay blocked:", error)
}
};
if (el.readyState >= 3) {
playVideo();
} else {
el.addEventListener("canplay", playVideo, { once: true });
}
}
setLoaded(true);
} else if (!entry.isIntersecting && loaded && shouldAutoplay) {
try {
el.pause();
} catch {}
} else if (entry.isIntersecting && loaded && shouldAutoplay) {
try {
await el.play();
} catch {}
}
});
};
observer = new IntersectionObserver(onIntersect, {
rootMargin: "80px",
threshold: 0.15,
});
observer.observe(el);
const onVisibility = () => {
if (!el) return;
const hidden = document.visibilityState === "hidden";
if (hidden) {
try {
el.pause();
} catch {}
} else if (shouldAutoplay && loaded) {
// resume only if we were auto-playing
el.play().catch(() => {});
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
observer?.disconnect();
};
}, [src, loaded, autoPlay]);
return (
<video
ref={videoRef}
className={className}
muted={muted}
loop={loop}
playsInline={playsInline}
controls={controls}
preload="none"
poster={poster}
aria-label={ariaLabel}
disableRemotePlayback
{...props}
>
Your browser does not support the video tag.
</video>
);
}Copy and paste the following code into your project component/ui/phone-card.tsx
"use client";
import { LazyVideo } from "./lazy-video";
export interface PhoneCardProps {
title?: string;
sub?: string;
tone?: string;
gradient?: string;
videoSrc?: string;
imageSrc?: string;
mediaType?: "video" | "image";
}
export function PhoneCard({
title = "8°",
sub = "Clear night. Great for render farm runs.",
tone = "calm",
gradient = "from-[#0f172a] via-[#14532d] to-[#052e16]",
videoSrc = "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/A%20new%20chapter%20in%20the%20story%20of%20success.__Introducing%20the%20new%20TAG%20Heuer%20Carrera%20Day-Date%20collection%2C%20reimagined%20with%20bold%20colors%2C%20refined%20finishes%2C%20and%20upgraded%20functionality%20to%20keep%20you%20focused%20on%20your%20goals.%20__Six%20-nDNoRQyFaZ8oaaoty4XaQz8W8E5bqA.mp4",
imageSrc = "https://www.jakala.com/hs-fs/hubfs/Vercel%20header-1.jpg?width=800&height=800&name=Vercel%20header-1.jpg",
mediaType = "video",
}: PhoneCardProps) {
return (
<div className="glass-border relative rounded-[28px] bg-neutral-900 p-2">
<div className="relative aspect-[9/19] w-full overflow-hidden rounded-2xl bg-black">
{mediaType === "video" ? (
<LazyVideo
src={videoSrc}
className="absolute inset-0 h-full w-full object-cover"
autoPlay={true}
loop={true}
muted={true}
playsInline={true}
aria-label={`${title} - ${sub}`}
/>
) : (
// biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx
<img
src={imageSrc}
alt={`${title} - ${sub}`}
className="absolute inset-0 h-full w-full object-cover"
/>
)}
{/* Gradient overlay */}
<div
className={`absolute inset-0 bg-gradient-to-b ${gradient} opacity-60 mix-blend-overlay`}
/>
<div className="relative z-10 p-3">
<div className="mx-auto mb-3 h-1.5 w-16 rounded-full bg-white/20" />
<div className="space-y-1 px-1">
<div className="font-bold text-3xl text-white/90 leading-snug">
{title}
</div>
<p className="text-white/70 text-xs">{sub}</p>
<div className="mt-3 inline-flex items-center rounded-full bg-black/40 px-2 py-0.5 text-[10px] text-lime-300 uppercase tracking-wider">
{tone === "calm" ? "Joly UI" : tone}
</div>
</div>
</div>
</div>
</div>
);
}Usage
import { PhoneCard } from "@/components/ui/phone-card";
export default function App() {
return (
<PhoneCard
title="8°"
sub="Clear night. Great for render farm runs."
tone="calm"
gradient="from-[#0f172a] via-[#14532d] to-[#052e16]"
videoSrc="https://example.com/video.mp4"
mediaType="video"
/>
);
}Examples
Image Mode
Use static images instead of video for better performance or when video isn't needed.
import { PhoneCard } from "@/components/ui/phone-card";
export function PhoneCardImageDemo() {
return (
<div className="flex items-center justify-center p-8">
<PhoneCard
title="Design"
sub="Beautiful UI components for modern applications"
tone="creative"
gradient="from-[#1e1b4b] via-[#312e81] to-[#4c1d95]"
imageSrc="https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=800&auto=format&fit=crop"
mediaType="image"
/>
</div>
);
}API Reference
PhoneCard
The main component that displays a phone mockup with media content.
Prop
Type
Advanced
The component uses the LazyVideo component internally, which provides:
- Intersection Observer for lazy loading
- Automatic pause/play based on visibility
- Support for prefers-reduced-motion
- Data saver mode detection
- Tab visibility handling
You can also use LazyVideo standalone:
import { LazyVideo } from "@/components/ui/lazy-video";
<LazyVideo
src="video.mp4"
autoPlay={true}
loop={true}
muted={true}
className="w-full h-full object-cover"
/>How is this guide?