Smart Lock
A smart lock card: tap the big shackle to lock or unlock with a short working state, auto-lock timer, and access log.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/lock-control.json'use client'
import { useEffect, useRef, useState } from 'react'
import './lock-control.css'
export interface LockLogEntry { who: string; what: string; when: string }
export interface LockControlProps {
locked?: boolean
autoLock?: number
door?: string
log?: LockLogEntry[]
onChange?: (locked: boolean) => void
fill?: 'dark' | 'light'
className?: string
}
/** Smart lock with working state, auto-lock and log. */
export function LockControl({ locked: l0 = true, autoLock = 30, door = 'Front door', log: g0 = [], onChange, fill = 'dark', className }: LockControlProps) {
const [locked, setLocked] = useState(l0)
const [busy, setBusy] = useState(false)
const [left, setLeft] = useState(0)
const [log, setLog] = useState(g0)
const t = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => () => clearTimeout(t.current), [])
const act = (to: boolean, who = 'You', how = 'app') => {
if (busy) return
setBusy(true)
t.current = setTimeout(() => {
setBusy(false); setLocked(to); onChange?.(to)
setLog((x) => [{ who, what: `${to ? 'Locked' : 'Unlocked'} · ${how}`, when: 'Just now' }, ...x].slice(0, 4))
setLeft(!to && autoLock ? autoLock : 0)
}, 900)
}
useEffect(() => {
if (locked || !left || busy) return
const id = setTimeout(() => { if (left <= 1) act(true, 'Auto-lock', 'timer'); else setLeft(left - 1) }, 1000)
return () => clearTimeout(id)
})
return (
<div className={['sd-lkc', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-lkc__h"><span>{door}</span><span>Battery <b>78%</b></span></div>
<button type="button" className="sd-lkc__b" aria-pressed={locked} data-busy={busy || undefined} aria-label={busy ? 'Working' : locked ? 'Unlock door' : 'Lock door'} onClick={() => act(!locked)}>
<svg viewBox="0 0 64 64" aria-hidden><rect x="16" y="28" width="32" height="26" rx="5" /><path className="sd-lkc__sh" d="M23 28v-7a9 9 0 0 1 18 0v7" /><path d="M32 38v6" /></svg>
</button>
<div className="sd-lkc__st" aria-live="polite"><b>{busy ? (locked ? 'Unlocking…' : 'Locking…') : locked ? 'Locked' : 'Unlocked'}</b><small>{busy ? 'Hold on' : !locked && left ? `Auto-locks in ${left}s` : locked ? 'Tap to unlock' : 'Tap to lock'}</small></div>
<ul aria-label="Recent activity">{log.map((e, i) => <li key={i}><i data-open={e.what.startsWith('Unlocked') || undefined} /><span><b>{e.who}</b> {e.what}</span><small>{e.when}</small></li>)}</ul>
</div>
)
}.sd-lkc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lkc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lkc :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lkc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lkc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lkc { width: min(100%, 300px); padding: 16px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 14px; justify-items: center; }
.sd-lkc__h { width: 100%; display: flex; justify-content: space-between; font-size: 12.5px; color: var(--mute); }
.sd-lkc__h b { color: var(--fg); }
.sd-lkc__b { width: 132px; height: 132px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1.5px var(--line); transition: background-color 250ms, color 250ms, transform 150ms; }
.sd-lkc__b:active { transform: scale(0.96); }
.sd-lkc__b[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-lkc__b[data-busy] { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-lkc__b svg { width: 52px; height: 52px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; overflow: visible; }
.sd-lkc__sh { transition: transform 300ms cubic-bezier(0.3, 1.4, 0.5, 1); transform-origin: 32px 26px; }
.sd-lkc__b[aria-pressed='false'] .sd-lkc__sh { transform: translateY(-7px); }
.sd-lkc__b[data-busy] svg { animation: sd-lkc-j 450ms ease-in-out infinite; }
@keyframes sd-lkc-j { 50% { transform: translateX(2px); } }
.sd-lkc__st { text-align: center; margin-top: -6px; }
.sd-lkc__st b { display: block; font-size: 17px; }
.sd-lkc__st small { color: var(--mute); font-size: 12px; font-variant-numeric: tabular-nums; }
.sd-lkc ul { width: 100%; list-style: none; margin: 0; padding: 10px 0 0; border-top: 1px solid var(--line); display: grid; gap: 8px; }
.sd-lkc li { display: flex; align-items: center; gap: 8px; font-size: 12.5px; }
.sd-lkc li i { width: 6px; height: 6px; border-radius: 50%; background: var(--fg); flex: none; }
.sd-lkc li i[data-open] { background: transparent; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-lkc li span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-lkc li small { color: var(--mute); font-size: 11px; }
@media (prefers-reduced-motion: reduce) { .sd-lkc *, .sd-lkc *::before, .sd-lkc *::after { animation: none !important; transition: none !important; } }Overview
The lock icon animates its shackle between states; while the bolt moves the button shows Working and ignores taps. Unlocking starts an auto-lock countdown.
Recent activity lists who used the door and how, newest first, including your own taps.
When to use
- Smart-home apps, building access.
When not to use
- Multi-door access control: use a table of doors.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/lock-control.json$
npx shadcn@latest add https://sundaeui.dev/r/lock-control.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/lock-control.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/lock-control.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy lock-control.tsx and lock-control.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { LockControl } from '@/components/sundae/lock-control'
<LockControl />Props
| Prop | Type | Default |
|---|---|---|
lockedInitial state. | boolean | true |
autoLockSeconds before re-locking; 0 to disable. | number | 30 |
logRecent activity. | { who: string; what: string; when: string }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The main control is a button with aria-pressed for locked and a text state.
- State changes and auto-lock are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lkc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Smart Lock free?
- Yes. Smart Lock is free for personal and commercial projects, with full source on this page.
- How do I install Smart Lock?
- Run npx shadcn@latest add https://sundaeui.dev/r/lock-control.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Smart Lock support keyboard and screen readers?
- The main control is a button with aria-pressed for locked and a text state. State changes and auto-lock are announced.
- Does Smart Lock respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Active Sessions
Components · RefinedThermostat
Components · RefinedLight Scenes
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined