A smartwatch drawn in CSS: rounded aluminum case, digital crown and side button, curved glass and a fading strap in sport, loop or leather. The screen is always dark and children render scaled to fit.
Right-to-left: Hardware buttons and bezels sit on fixed physical edges of the device, so the frame keeps its shape; the screen content you place inside follows the page direction.
import { WatchFrame } from "@/components/ballmac/watch-frame"
const ticks = Array.from({ length: 60 }, (_, i) => i)
function AnalogFace() {
// 10:09:30 — the classic watch-advert time, so server and browser always agree.
const hour = (10 + 9.5 / 60) * 30
const minute = 9.5 * 6
const second = 30 * 6
return (
<div className="relative size-full bg-black">
<svg viewBox="0 0 208 248" className="size-full" role="img" aria-label="Analog watch face showing 10:09">
<g transform="translate(104 124)">
{ticks.map((i) => {
const major = i % 5 === 0
return <line key={i} x1="0" y1={major ? -98 : -102} x2="0" y2="-108" transform={`rotate(${i * 6})`} stroke={major ? "white" : "rgb(255 255 255 / 0.4)"} strokeWidth={major ? 3 : 1.2} strokeLinecap="round" />
})}
{[12, 3, 6, 9].map((n, i) => (
<text key={n} x={Math.sin((i * 90 * Math.PI) / 180) * 80} y={-Math.cos((i * 90 * Math.PI) / 180) * 80 + 8} textAnchor="middle" fontSize="22" fontWeight="600" fill="white" fontFamily="ui-rounded, system-ui, sans-serif">
{n}
</text>
))}
{/* Complications */}
<g>
<circle cx="-42" cy="8" r="26" fill="rgb(255 255 255 / 0.08)" />
<circle cx="-42" cy="8" r="20" fill="none" stroke="oklch(0.7 0.2 25)" strokeWidth="5" strokeLinecap="round" strokeDasharray="88 126" transform="rotate(-90 -42 8)" />
<text x="-42" y="13" textAnchor="middle" fontSize="14" fontWeight="700" fill="white" fontFamily="ui-rounded, system-ui, sans-serif">70</text>
<circle cx="42" cy="8" r="26" fill="rgb(255 255 255 / 0.08)" />
<text x="42" y="3" textAnchor="middle" fontSize="9" fontWeight="600" fill="oklch(0.78 0.16 35)" fontFamily="ui-rounded, system-ui, sans-serif">THU</text>
<text x="42" y="22" textAnchor="middle" fontSize="19" fontWeight="700" fill="white" fontFamily="ui-rounded, system-ui, sans-serif">1</text>
</g>
<line x1="0" y1="6" x2="0" y2="-52" transform={`rotate(${hour})`} stroke="white" strokeWidth="7" strokeLinecap="round" />
<line x1="0" y1="6" x2="0" y2="-82" transform={`rotate(${minute})`} stroke="white" strokeWidth="5" strokeLinecap="round" />
<line x1="0" y1="14" x2="0" y2="-90" transform={`rotate(${second})`} stroke="oklch(0.78 0.17 55)" strokeWidth="2" strokeLinecap="round" />
<circle r="5.5" fill="oklch(0.78 0.17 55)" />
<circle r="2" fill="black" />
</g>
</svg>
</div>
)
}
export default function WatchFrameDemo() {
return (
<div className="flex w-full justify-center px-2 py-6">
<WatchFrame screenWidth={208} band="sport" bandTone="blue" className="max-w-[250px]">
<AnalogFace />
</WatchFrame>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/watch-frameInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/mediaCopy the source into your project.
components/ballmac/watch-frame.tsx// Ballmac UI: Watch Frame. https://ui.ballmac.com/components/watch-frame "use client" import * as React from "react" import { useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { Media } from "@/components/ballmac/media" type WatchFrameProps = Omit<React.ComponentProps<"div">, "children"> & { /** Screen content. Ignored when `src` or `videoSrc` is set. Defaults to a dark screen. */ children?: React.ReactNode /** Finish of the aluminum case. "auto" is silver in light mode and black in dark mode. */ variant?: "auto" | "silver" | "black" | "gold" /** Strap style and color. */ band?: "sport" | "loop" | "leather" | "none" /** Strap color, taken from the theme's chart colors. */ bandTone?: "blue" | "teal" | "orange" | "purple" | "graphite" | "red" /** Image shown on the screen, cropped to fill it. */ src?: string /** Alternative text for `src`. */ alt?: string /** Video shown on the screen: muted, looping, and paused under reduced motion. */ videoSrc?: string /** Poster image for `videoSrc`. */ poster?: string /** * Width in CSS pixels that the screen content is laid out at (208 matches a 46 mm watch); * it is then scaled to fit. Omit to lay content out at the frame's actual size. */ screenWidth?: number /** Class names for the screen element. */ screenClassName?: string } const SILVER = "[--wf-hi:color-mix(in_oklch,white_96%,black)] [--wf-mid:color-mix(in_oklch,white_80%,black)] [--wf-lo:color-mix(in_oklch,white_58%,black)]" const BLACK = "[--wf-hi:color-mix(in_oklch,white_32%,black)] [--wf-mid:color-mix(in_oklch,white_16%,black)] [--wf-lo:color-mix(in_oklch,white_8%,black)]" const BLACK_DARK = "dark:[--wf-hi:color-mix(in_oklch,white_32%,black)] dark:[--wf-mid:color-mix(in_oklch,white_16%,black)] dark:[--wf-lo:color-mix(in_oklch,white_8%,black)]" const GOLD = "[--wf-hi:color-mix(in_oklch,var(--chart-3)_35%,white)] [--wf-mid:color-mix(in_oklch,var(--chart-3)_60%,white)] [--wf-lo:color-mix(in_oklch,var(--chart-3)_75%,black)]" const TONES = { blue: "[--wf-band:color-mix(in_oklch,var(--chart-1)_92%,black)]", teal: "[--wf-band:color-mix(in_oklch,var(--chart-2)_92%,black)]", orange: "[--wf-band:color-mix(in_oklch,var(--chart-5)_92%,black)]", purple: "[--wf-band:color-mix(in_oklch,var(--chart-4)_92%,black)]", red: "[--wf-band:color-mix(in_oklch,var(--destructive)_92%,black)]", graphite: "[--wf-band:color-mix(in_oklch,white_26%,black)]", } function useScreenScale(screenWidth: number | undefined) { const ref = React.useRef<HTMLDivElement>(null) React.useEffect(() => { const screen = ref.current if (!screen || !screenWidth) return const measure = () => screen.style.setProperty("--screen-scale", String(screen.clientWidth / screenWidth)) measure() const observer = new ResizeObserver(measure) observer.observe(screen) return () => observer.disconnect() }, [screenWidth]) return ref } /** * A smartwatch drawn in CSS: rounded case with a brushed highlight, digital crown, side button, curved glass and a strap * that fades out above and below. The screen is always dark. Children render as the screen, laid out at a watch resolution and scaled to fit. */ function WatchFrame({ variant = "auto", band = "sport", bandTone = "graphite", src, alt = "", videoSrc, poster, screenWidth, screenClassName, className, children, ...props }: WatchFrameProps) { const media = !!(src || videoSrc) const viewportRef = useScreenScale(media ? undefined : screenWidth) const reduceMotion = !!useReducedMotion() const strap = (side: "top" | "bottom") => ( <span aria-hidden="true" data-slot="watch-frame-band" className={cn( "absolute left-1/2 h-[34cqw] w-[78cqw] -translate-x-1/2 bg-(--wf-band)", side === "top" ? "top-0 rounded-t-[8cqw] [mask-image:linear-gradient(to_top,black_55%,transparent)]" : "bottom-0 rounded-b-[8cqw] [mask-image:linear-gradient(to_bottom,black_55%,transparent)]", band === "sport" && "bg-[linear-gradient(90deg,rgb(0_0_0/0.28),transparent_14%,transparent_86%,rgb(0_0_0/0.28)),linear-gradient(var(--wf-band),var(--wf-band))]", band === "loop" && "bg-[repeating-linear-gradient(0deg,rgb(255_255_255/0.1)_0_0.5cqw,transparent_0.5cqw_1.4cqw),linear-gradient(90deg,rgb(0_0_0/0.28),transparent_14%,transparent_86%,rgb(0_0_0/0.28)),linear-gradient(var(--wf-band),var(--wf-band))]", band === "leather" && "bg-[linear-gradient(90deg,rgb(0_0_0/0.3),transparent_12%,transparent_88%,rgb(0_0_0/0.3)),linear-gradient(color-mix(in_oklch,var(--wf-band)_70%,black),color-mix(in_oklch,var(--wf-band)_70%,black))]" )} > {band === "sport" && side === "bottom" && ( <> <span className="absolute top-[9cqw] left-1/2 size-[2.4cqw] -translate-x-1/2 rounded-full bg-black/35" /> <span className="absolute top-[15cqw] left-1/2 size-[2.4cqw] -translate-x-1/2 rounded-full bg-black/35" /> <span className="absolute top-[21cqw] left-1/2 size-[2.4cqw] -translate-x-1/2 rounded-full bg-black/35" /> </> )} {band === "leather" && <span className="absolute inset-y-0 left-[6cqw] border-l-[0.5cqw] border-dashed border-white/30" />} </span> ) return ( <div data-slot="watch-frame" data-variant={variant} className={cn( "@container relative aspect-[100/181] w-full max-w-[200px] select-none", variant === "silver" && SILVER, variant === "black" && BLACK, variant === "gold" && GOLD, variant === "auto" && cn(SILVER, BLACK_DARK), TONES[bandTone], className )} {...props} > {band !== "none" && ( <> {strap("top")} {strap("bottom")} </> )} {/* Digital crown and side button */} <span aria-hidden="true" className="absolute top-[63cqw] right-[-2.6cqw] h-[16cqw] w-[4.6cqw] rounded-r-[1.8cqw] bg-[repeating-linear-gradient(0deg,var(--wf-hi)_0_0.6cqw,var(--wf-lo)_0.6cqw_1.2cqw)] shadow-[0_0.4cqw_1cqw_rgb(0_0_0/0.3)]" /> <span aria-hidden="true" className="absolute top-[88cqw] right-[-1.6cqw] h-[16cqw] w-[2.8cqw] rounded-r-[1.2cqw] bg-[linear-gradient(90deg,var(--wf-lo),var(--wf-hi)_60%,var(--wf-mid))]" /> {/* Case */} <div data-slot="watch-frame-body" className="absolute top-[30cqw] left-0 h-[121cqw] w-full rounded-[27cqw] bg-[linear-gradient(135deg,var(--wf-hi),var(--wf-mid)_30%,var(--wf-lo)_56%,var(--wf-mid)_80%,var(--wf-hi))] p-[1.6cqw] shadow-[inset_0_0_0_0.3cqw_rgb(255_255_255/0.16),0_3cqw_7cqw_-1.5cqw_rgb(0_0_0/0.45),0_0.6cqw_1.6cqw_rgb(0_0_0/0.25)]" > <div className="size-full rounded-[25.6cqw] bg-black p-[3.2cqw] shadow-[inset_0_0_0_0.4cqw_rgb(255_255_255/0.08)]"> <div data-slot="watch-frame-screen" className={cn("dark relative isolate flex size-full flex-col overflow-hidden rounded-[22.4cqw] bg-black text-white", screenClassName)} > {media ? ( videoSrc ? ( <video data-slot="watch-frame-video" className="absolute inset-0 size-full object-cover" src={videoSrc} poster={poster} autoPlay={!reduceMotion} muted loop playsInline /> ) : ( // eslint-disable-next-line @next/next/no-img-element <Media data-slot="watch-frame-image" media={src} alt={alt} fill className="absolute inset-0" /> ) ) : ( <div ref={viewportRef} data-slot="watch-frame-content" className="relative min-h-0 flex-1"> {screenWidth ? ( <div className="absolute top-0 left-0 origin-top-left" style={{ width: screenWidth, height: "calc(100% / var(--screen-scale, 1))", transform: "scale(var(--screen-scale, 1))" }} > {children} </div> ) : ( children )} </div> )} {/* Curved glass */} <span aria-hidden="true" className="pointer-events-none absolute inset-0 z-40 rounded-[inherit] bg-[linear-gradient(125deg,rgb(255_255_255/0.14)_0%,rgb(255_255_255/0.02)_32%,transparent_32.2%)] shadow-[inset_0_0_6cqw_rgb(255_255_255/0.05)]" /> </div> </div> </div> </div> ) } export { WatchFrame, type WatchFrameProps }Update the import paths to match your project setup.
Usage
import { WatchFrame } from "@/components/ballmac/watch-frame"The full example is in the Code tab above.
Examples
Workout, loop band
Outdoor Run
24:18
import { Flame, Footprints, Heart } from "lucide-react"
import { WatchFrame } from "@/components/ballmac/watch-frame"
function Workout() {
return (
<div className="flex h-full flex-col px-4 pt-3 pb-5 text-white">
<p className="flex items-center gap-1.5 text-[15px] font-semibold text-[oklch(0.8_0.18_135)]">
<Footprints className="size-4" aria-hidden="true" /> Outdoor Run
</p>
<p className="mt-1 text-[44px] leading-none font-semibold tracking-tight tabular-nums">24:18</p>
<div className="mt-auto grid gap-2">
<div className="flex items-baseline justify-between">
<span className="flex items-center gap-1.5 text-[22px] font-semibold tabular-nums">
<Heart className="size-4 fill-[oklch(0.7_0.22_20)] text-[oklch(0.7_0.22_20)]" aria-hidden="true" />148
</span>
<span className="text-[13px] text-white/75">BPM</span>
</div>
<div className="flex items-baseline justify-between">
<span className="flex items-center gap-1.5 text-[22px] font-semibold tabular-nums">
<Flame className="size-4 fill-[oklch(0.8_0.17_60)] text-[oklch(0.8_0.17_60)]" aria-hidden="true" />312
</span>
<span className="text-[13px] text-white/75">CAL</span>
</div>
<div className="flex items-baseline justify-between">
<span className="text-[22px] font-semibold tabular-nums">5'42"</span>
<span className="text-[13px] text-white/75">PACE</span>
</div>
</div>
</div>
)
}
export default function WatchFrameWorkout() {
return (
<div className="flex w-full justify-center px-2 py-6">
<WatchFrame screenWidth={208} band="loop" bandTone="orange" variant="black" className="max-w-[250px]">
<Workout />
</WatchFrame>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
childrenScreen content. Ignored when `src` or `videoSrc` is set. Defaults to a dark screen. | React.ReactNode | — |
variantFinish of the aluminum case. "auto" is silver in light mode and black in dark mode. | "auto" | "silver" | "black" | "gold" | "auto" |
bandStrap style and color. | "sport" | "loop" | "leather" | "none" | "sport" |
bandToneStrap color, taken from the theme's chart colors. | "blue" | "teal" | "orange" | "purple" | "graphite" | "red" | "graphite" |
srcImage shown on the screen, cropped to fill it. | string | — |
altAlternative text for `src`. | string | "" |
videoSrcVideo shown on the screen: muted, looping, and paused under reduced motion. | string | — |
posterPoster image for `videoSrc`. | string | — |
screenWidthWidth in CSS pixels that the screen content is laid out at (208 matches a 46 mm watch); it is then scaled to fit. Omit to lay content out at the frame's actual size. | number | — |
screenClassNameClass names for the screen element. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Static | Purely visual: no focusable elements from the frame |
| Reduced motion | Video on the screen is not autoplayed |
Use with AI
<WatchFrame screenWidth={208} band="sport" bandTone="blue"> renders children as the watch screen at 208 CSS px wide, scaled to fit. Set the width with className (about w-40). With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Watch Frame (@ballmac/watch-frame) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Wearable app showcases
- Device lineups next to phone-frame
Not for
- Large UI that needs a normal screen (phone-frame)
Registry JSON: https://ui.ballmac.com/r/watch-frame.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/mediashadcn/utils
Pairs well with
Phone Frame
A modern phone drawn in CSS: titanium band, side buttons, Dynamic Island, status bar and home indicator. Children render as the screen, laid out at a phone resolution and scaled to fit.
Tablet Frame
A modern tablet drawn in CSS: brushed aluminum frame, thin glass bezel, front camera, status bar and home indicator. Landscape or portrait with the same proportions; children render as the screen at tablet resolution, scaled to fit.
Progress Ring
A token-colored circular progress meter with a readable center and accessible value.
Android Frame
A modern Android phone drawn in CSS: satin frame in porcelain, obsidian or sage, centered punch-hole camera, Material-style status bar and gesture or three-button navigation. Children render at phone resolution, scaled to fit.