A full-screen lock screen in two flavors: macOS (live clock, avatar, password field that shakes when wrong) and iOS (clock, notification stack, flashlight and camera, swipe up to unlock). Hydration-safe clock.
Welcome back, Alex
Thu Oct 1
9:41
Alex Morgan
Hint: the password is “hello”
Type a wrong password to see it shake, then “hello” to unlock.
"use client"
import * as React from "react"
import { Lock } from "lucide-react"
import { LockScreen } from "@/components/ballmac/lock-screen"
export default function LockScreenDemo() {
const [locked, setLocked] = React.useState(true)
return (
<div className="flex w-full max-w-3xl flex-col gap-3">
<div className="relative isolate h-[440px] overflow-hidden rounded-2xl border border-foreground/10">
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-linear-to-br from-chart-2 via-chart-1 to-chart-4" />
<div className="flex h-full flex-col items-center justify-center gap-4 text-center text-white">
<p className="text-2xl font-semibold [text-shadow:0_1px_8px_rgb(0_0_0/0.4)]">Welcome back, Alex</p>
<button
type="button"
onClick={() => setLocked(true)}
className="inline-flex h-9 items-center gap-2 rounded-full bg-black/35 px-4 text-sm font-medium outline-none backdrop-blur hover:bg-black/45 focus-visible:ring-[3px] focus-visible:ring-white/70"
>
<Lock className="size-3.5" aria-hidden="true" /> Lock screen
</button>
</div>
<LockScreen locked={locked} onLockedChange={setLocked} password="hello" name="Alex Morgan" hint="Hint: the password is “hello”" />
</div>
<p className="text-sm text-muted-foreground">Type a wrong password to see it shake, then “hello” to unlock.</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/lock-screenInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presets @ballmac/i18n @ballmac/mediaCopy the source into your project.
components/ballmac/lock-screen.tsx// Ballmac UI: Lock Screen. https://ui.ballmac.com/components/lock-screen "use client" import * as React from "react" import { ArrowRight, Camera, Flashlight, Lock } from "lucide-react" import { AnimatePresence, motion, useAnimationControls } from "motion/react" import { cn } from "@/lib/utils" import { useReducedMotionSafe } from "@/lib/ballmac/motion" import { useMessages } from "@/lib/ballmac/i18n" import { Media, isMediaImage, type MediaSource } from "@/components/ballmac/media" type LockScreenProps = Omit<React.ComponentProps<"section">, "onSubmit"> & { /** "mac" shows a password field under the user's avatar; "ios" shows notifications and a swipe-up unlock. */ variant?: "mac" | "ios" /** Controlled state. While true the lock screen covers its positioned parent. */ locked?: boolean /** Initial state when uncontrolled. */ defaultLocked?: boolean /** Called with the new state when the screen unlocks. */ onLockedChange?: (locked: boolean) => void /** Time to show, such as "9:41". Omit for a live clock. */ time?: string /** Date to show, such as "Thursday, October 1". Omit for the live date. */ date?: string /** Name under the avatar (mac). */ name?: string /** Replaces the default avatar (mac): an image, icon or initials. */ avatar?: React.ReactNode /** The correct password (mac). Without it, any entry unlocks. */ password?: string /** Extra check on the entered password (mac). Return false to reject it. */ onUnlock?: (password: string) => boolean | void /** Hint under the field (mac). */ hint?: string /** Replaces the default wallpaper. An image URL, an object with alt text and a dark-mode file, or an element that fills it; text stays white, so keep it dark enough. */ wallpaper?: MediaSource /** Notifications on iOS, or controls along the bottom on mac. */ children?: React.ReactNode } function useClock(fixedTime?: string, fixedDate?: string) { const [now, setNow] = React.useState<Date | null>(null) React.useEffect(() => { if (fixedTime && fixedDate) return let timer: ReturnType<typeof setTimeout> const tick = () => { const d = new Date() setNow(d) timer = setTimeout(tick, 60000 - (d.getSeconds() * 1000 + d.getMilliseconds()) + 50) } tick() return () => clearTimeout(timer) }, [fixedTime, fixedDate]) return { now } } function format(now: Date | null, variant: "mac" | "ios") { // Until mounted, render a fixed value so server and browser agree. if (!now) return { time: "9:41", date: variant === "ios" ? "Thursday, October 1" : "Thu Oct 1" } const time = now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }).replace(/\s?[AP]M$/i, "") const date = variant === "ios" ? now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }) : now.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }).replace(",", "") return { time, date } } function DefaultWallpaper() { return ( <div aria-hidden="true" data-slot="lock-screen-wallpaper" className="absolute inset-0 overflow-hidden bg-[oklch(0.3_0.1_282)]"> <div className="absolute -top-1/4 -start-1/4 size-[85%] rounded-full bg-[oklch(0.5_0.2_305)] opacity-70 blur-[70px]" /> <div className="absolute -end-1/4 bottom-[-20%] size-[90%] rounded-full bg-[oklch(0.5_0.17_28)] opacity-70 blur-[80px]" /> <div className="absolute top-1/3 start-1/3 size-[55%] rounded-full bg-[oklch(0.45_0.15_235)] opacity-60 blur-[70px]" /> </div> ) } /** * A full-screen lock screen in two flavors: macOS (big clock, avatar, password field that shakes when wrong) and * iOS (date, huge clock, notification stack, flashlight and camera buttons, swipe up to unlock). * It covers its positioned parent and slides or fades away when unlocked. The clock is live and hydration-safe. */ function LockScreen({ variant = "mac", locked: lockedProp, defaultLocked = true, onLockedChange, time, date, name = "Alex Morgan", avatar, password, onUnlock, hint, wallpaper, children, className, ...props }: LockScreenProps) { const msg = useMessages() hint ??= msg("lock-screen.hint", "Touch ID or enter password") const reduce = useReducedMotionSafe() const uid = React.useId() const [lockedState, setLockedState] = React.useState(defaultLocked) const locked = lockedProp ?? lockedState const { now } = useClock(time, date) const shown = format(now, variant) const clock = time ?? shown.time const day = date ?? shown.date const [value, setValue] = React.useState("") const [failed, setFailed] = React.useState(false) const shake = useAnimationControls() const inputRef = React.useRef<HTMLInputElement>(null) function unlock() { setLockedState(false) setValue("") setFailed(false) onLockedChange?.(false) } // A lock screen that comes back starts clean. React.useEffect(() => { if (locked) { setValue("") setFailed(false) } }, [locked]) async function submit(e: React.FormEvent) { e.preventDefault() const ok = (password === undefined || value === password) && onUnlock?.(value) !== false if (ok) return unlock() setFailed(true) setValue("") inputRef.current?.focus() if (!reduce) await shake.start({ x: [0, -12, 11, -9, 7, -4, 2, 0], transition: { duration: 0.45, ease: "easeOut" } }) } const exit = reduce ? { opacity: 0 } : variant === "ios" ? { y: "-100%", transition: { duration: 0.45, ease: [0.32, 0.72, 0, 1] as const } } : { opacity: 0, scale: 1.08, filter: "blur(12px)", transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] as const } } return ( <AnimatePresence> {locked && ( <motion.section key="lock" data-slot="lock-screen" data-variant={variant} aria-label={msg("lock-screen.lockScreen", "Lock screen")} className={cn("@container absolute inset-0 z-50 flex select-none flex-col overflow-hidden text-white", className)} initial={false} exit={exit} {...(variant === "ios" && !reduce ? { drag: "y" as const, dragConstraints: { top: 0, bottom: 0 }, dragElastic: { top: 0.6, bottom: 0 }, onDragEnd: (_: unknown, info: { offset: { y: number }; velocity: { y: number } }) => { if (info.offset.y < -110 || info.velocity.y < -600) unlock() }, } : {})} {...(props as object)} > {isMediaImage(wallpaper) ? <Media media={wallpaper} alt="" fill priority className="absolute inset-0" /> : (wallpaper ?? <DefaultWallpaper />)} <div aria-hidden="true" className="absolute inset-0 bg-black/20" /> {variant === "ios" ? ( <div className="relative flex min-h-0 flex-1 flex-col items-center px-[6cqw] pt-[14cqw] pb-[9cqw]"> <p className="text-[clamp(0.8rem,4.6cqw,1.35rem)] font-medium text-white/90 [text-shadow:0_1px_8px_rgb(0_0_0/0.3)]">{day}</p> <p className="-mt-[1cqw] bg-gradient-to-b from-white to-white/70 bg-clip-text text-[clamp(4rem,23cqw,9rem)] leading-[1.05] font-semibold tracking-tight tabular-nums text-transparent"> {clock} </p> <div className="mt-[6cqw] flex min-h-0 w-full max-w-[34rem] flex-1 flex-col gap-[2cqw] overflow-hidden">{children}</div> <div className="mt-[3cqw] flex w-full max-w-[34rem] items-center justify-between px-[2cqw]"> {[ [Flashlight, "Flashlight"], [Camera, "Camera"], ].map(([Glyph, label]) => { const Icon = Glyph as typeof Flashlight return ( <button key={label as string} type="button" aria-label={label as string} className="flex size-[clamp(2.75rem,12.5cqw,4rem)] items-center justify-center rounded-full bg-black/35 text-white outline-none backdrop-blur-xl transition-colors hover:bg-black/45 focus-visible:ring-[3px] focus-visible:ring-white/70 [&_svg]:size-[45%]" > <Icon aria-hidden="true" /> </button> ) })} </div> <button type="button" onClick={unlock} className="mt-[3cqw] rounded-full px-4 py-2 text-[clamp(0.75rem,3.4cqw,0.95rem)] font-medium text-white/85 outline-none focus-visible:ring-[3px] focus-visible:ring-white/70" > <span aria-hidden="true" className="mx-auto mb-2 block h-1 w-28 rounded-full bg-white/85" /> {reduce ? "Unlock" : "Swipe up to unlock"} </button> </div> ) : ( <div className="relative flex min-h-0 flex-1 flex-col items-center px-6 pt-[clamp(2rem,6vw,4rem)] pb-6"> <p className="text-[clamp(1rem,2.4cqw,1.5rem)] font-medium text-white/90 [text-shadow:0_1px_8px_rgb(0_0_0/0.3)]">{day}</p> <p className="bg-gradient-to-b from-white to-white/75 bg-clip-text text-[clamp(4.5rem,13cqw,9rem)] leading-none font-bold tracking-tight tabular-nums text-transparent"> {clock} </p> <div className="mt-auto flex w-full flex-col items-center"> <div className="flex size-[4.75rem] items-center justify-center overflow-hidden rounded-full bg-white/20 text-3xl font-semibold shadow-[0_2px_12px_rgb(0_0_0/0.35)] ring-1 ring-white/30 backdrop-blur-xl [&>img]:size-full [&>img]:object-cover [&>svg]:size-[55%]"> {avatar ?? <span aria-hidden="true">{name.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase()}</span>} </div> <p className="mt-3 text-lg font-semibold [text-shadow:0_1px_6px_rgb(0_0_0/0.35)]">{name}</p> <motion.form animate={shake} onSubmit={submit} className="relative mt-3 w-full max-w-[15rem]" noValidate> <label htmlFor={`${uid}-pw`} className="sr-only"> {msg("lock-screen.passwordFor", "Password for {name}", { name })} </label> <input ref={inputRef} id={`${uid}-pw`} type="password" autoComplete="current-password" value={value} onChange={(e) => setValue(e.target.value)} placeholder={msg("lock-screen.enterPassword", "Enter Password")} aria-invalid={failed || undefined} aria-describedby={`${uid}-hint`} className="h-9 w-full rounded-full border border-white/25 bg-white/20 pe-10 ps-4 text-[15px] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.2)] outline-none backdrop-blur-xl placeholder:text-white/80 focus-visible:border-white/60 focus-visible:ring-[3px] focus-visible:ring-white/40" /> <button type="submit" aria-label={msg("lock-screen.unlock", "Unlock")} className="absolute top-1/2 end-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded-full bg-white/30 text-white outline-none transition-colors hover:bg-white/45 focus-visible:ring-2 focus-visible:ring-white" > <ArrowRight aria-hidden="true" className="size-3.5 rtl:rotate-180" strokeWidth={2.75} /> </button> </motion.form> <p id={`${uid}-hint`} role={failed ? "alert" : undefined} className="mt-2 flex items-center gap-1.5 text-xs font-medium text-white/85 [text-shadow:0_1px_4px_rgb(0_0_0/0.4)]"> {failed ? ( "Incorrect password. Try again." ) : ( <> <Lock aria-hidden="true" className="size-3" /> {hint} </> )} </p> {children && <div className="mt-6 flex items-center justify-center gap-6">{children}</div>} </div> </div> )} </motion.section> )} </AnimatePresence> ) } export { LockScreen, type LockScreenProps }Update the import paths to match your project setup.
Usage
import { LockScreen } from "@/components/ballmac/lock-screen"The full example is in the Code tab above.
Examples
iOS in a phone
Home Screen
Thursday, October 1
9:41
Messagesnow
Sam
Are we still on for dinner tonight? I booked the table by the window.
Calendar9m ago
Design review
Starts in 15 minutes · Room 4B
"use client"
import * as React from "react"
import { Calendar, MessageCircle } from "lucide-react"
import { LockScreen } from "@/components/ballmac/lock-screen"
import { PhoneFrame } from "@/components/ballmac/phone-frame"
function Notification({ icon, app, title, body, when }: { icon: React.ReactNode; app: string; title: string; body: string; when: string }) {
return (
<div className="flex gap-3 rounded-[22px] bg-white/20 p-3.5 backdrop-blur-2xl">
<span className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-white/90 text-neutral-900 [&_svg]:size-5">{icon}</span>
<div className="min-w-0 leading-tight">
<p className="flex justify-between gap-2 text-[13px] text-white/85">
<span className="font-medium">{app}</span>
<span>{when}</span>
</p>
<p className="mt-0.5 truncate text-[15px] font-semibold">{title}</p>
<p className="line-clamp-2 text-[14px] text-white/90">{body}</p>
</div>
</div>
)
}
export default function LockScreenIos() {
const [locked, setLocked] = React.useState(true)
return (
<div className="flex w-full flex-col items-center gap-4 py-4">
<PhoneFrame screenWidth={390} statusBar={false} screenClassName="dark" className="max-w-[280px]">
<div className="relative h-full bg-[linear-gradient(170deg,oklch(0.36_0.12_260),oklch(0.32_0.12_320))]">
<p className="pt-24 text-center text-lg font-semibold text-white">Home Screen</p>
<LockScreen variant="ios" locked={locked} onLockedChange={setLocked}>
<Notification icon={<MessageCircle aria-hidden="true" />} app="Messages" title="Sam" body="Are we still on for dinner tonight? I booked the table by the window." when="now" />
<Notification icon={<Calendar aria-hidden="true" />} app="Calendar" title="Design review" body="Starts in 15 minutes · Room 4B" when="9m ago" />
</LockScreen>
</div>
</PhoneFrame>
<button
type="button"
onClick={() => setLocked(true)}
className="inline-flex h-9 items-center rounded-md border bg-background px-3 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Lock again
</button>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
variant"mac" shows a password field under the user's avatar; "ios" shows notifications and a swipe-up unlock. | "mac" | "ios" | "mac" |
lockedControlled state. While true the lock screen covers its positioned parent. | boolean | — |
defaultLockedInitial state when uncontrolled. | boolean | true |
onLockedChangeCalled with the new state when the screen unlocks. | (locked: boolean) => void | — |
timeTime to show, such as "9:41". Omit for a live clock. | string | — |
dateDate to show, such as "Thursday, October 1". Omit for the live date. | string | — |
nameName under the avatar (mac). | string | "Alex Morgan" |
avatarReplaces the default avatar (mac): an image, icon or initials. | React.ReactNode | — |
passwordThe correct password (mac). Without it, any entry unlocks. | string | — |
onUnlockExtra check on the entered password (mac). Return false to reject it. | (password: string) => boolean | void | — |
hintHint under the field (mac). | string | — |
wallpaperReplaces the default wallpaper. An image URL, an object with alt text and a dark-mode file, or an element that fills it; text stays white, so keep it dark enough. | MediaSource | — |
childrenNotifications on iOS, or controls along the bottom on mac. | React.ReactNode | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Reaches the password field and the Unlock button |
| Enter | Submits the password |
| Wrong password | Announced as an alert and the field shakes (no shake with reduced motion) |
| Reduced motion | Fade instead of slide, blur and scale; iOS shows an Unlock button |
Use with AI
<LockScreen variant="mac" password="hello" /> fills its positioned parent until unlocked. variant="ios" takes notifications as children and unlocks by swiping up or pressing the button. Control with locked / onLockedChange. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Lock Screen (@ballmac/lock-screen) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Mac or phone UI demos with a login moment
- Idle or session-timeout screens
Not for
- Real authentication: the password prop is for demos, check credentials on a server
Registry JSON: https://ui.ballmac.com/r/lock-screen.json
Credits
Free to use in personal and commercial projects.
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.
Notification Stack
macOS Notification Center cards: collapsed, the newest sits in front with two peeking behind; click or press Enter to spring the stack open. New cards slide in from the top; each can be dismissed.
Mac Window
macOS window chrome: traffic lights whose glyphs appear on hover, a centered title or unified toolbar, an optional translucent sidebar with vibrancy, and active and inactive appearances.
Menu Bar
A translucent macOS menu bar on Radix Menubar: a bold app menu, menus with key equivalents, submenus and checkmarks, a status area with icon buttons and a hydration-safe live clock.