Components

Phone Card

A beautiful phone mockup card component with lazy-loading video support.

Last updated on

Edit on GitHub
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?

On this page