Components & interactionsCountdown timer dial
Interval clock
A single enamel countdown dial depletes around an inset numeric readout, with tactile interval keys.

Choose 1, 5 or 10 minutes; start, pause or reset.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { useEffect, useId, useRef, useState } from "react"
export default function IntervalClock({ active = true }: { active?: boolean }) {
const [duration, setDuration] = useState(5)
const [remaining, setRemaining] = useState(300)
const [running, setRunning] = useState(false)
const remainingRef = useRef(300)
const id = useId().replace(/:/g, "")
useEffect(() => {
if (!active || !running) return
let previous = performance.now()
const tick = () => {
const now = performance.now()
remainingRef.current = Math.max(0, remainingRef.current - (now - previous) / 1000)
previous = now
setRemaining(Math.ceil(remainingRef.current))
if (remainingRef.current === 0) setRunning(false)
}
const timer = setInterval(tick, 100)
return () => clearInterval(timer)
}, [active, running])
const reset = (minutes = duration) => {
setDuration(minutes)
setRunning(false)
remainingRef.current = minutes * 60
setRemaining(minutes * 60)
}
const progress = remaining / (duration * 60)
return (
<section className="sf-interval-clock">
<div className="sf-ic-instrument" data-running={running && active}>
<div className="sf-ic-face">
<svg viewBox="0 0 256 256" aria-hidden="true">
<defs>
<radialGradient id={id + "face"} cx="40%" cy="25%">
<stop stopColor="#fcfcf8" />
<stop offset="1" stopColor="#dcddd6" />
</radialGradient>
<linearGradient id={id + "bezel"} x2=".7" y2="1">
<stop stopColor="#ffffff" />
<stop offset=".42" stopColor="#a7ada7" />
<stop offset=".6" stopColor="#f6f7ef" />
<stop offset="1" stopColor="#808b84" />
</linearGradient>
</defs>
<circle cx="128" cy="128" r="126" fill={"url(#" + id + "bezel)"} />
<circle cx="128" cy="128" r="119" fill={"url(#" + id + "face)"} stroke="#a6ada6" />
{Array.from({ length: 60 }, (_, i) => (
<line
key={i}
x1="128"
y1={i % 5 === 0 ? "30" : "24"}
x2="128"
y2="18"
stroke={i % 5 === 0 ? "#263c34" : "#8c9890"}
strokeWidth={i % 5 === 0 ? "2" : "1"}
transform={`rotate(${i * 6} 128 128)`}
/>
))}
<circle cx="128" cy="128" r="87" fill="none" stroke="#c9cfc6" strokeWidth="7" />
<circle
className="sf-ic-progress"
cx="128"
cy="128"
r="87"
fill="none"
stroke="#df6435"
strokeWidth="7"
pathLength="1"
strokeDasharray={`${progress} 1`}
transform="rotate(-90 128 128)"
/>
<circle
cx={128 + 87 * Math.sin(progress * Math.PI * 2)}
cy={128 - 87 * Math.cos(progress * Math.PI * 2)}
r="5"
fill="#8d351e"
stroke="#e9875e"
strokeWidth="1"
/>
</svg>
<div className="sf-ic-display">
<span className="sf-ic-lamp" />
<output aria-label="Time remaining" aria-live="off">
{String(Math.floor(remaining / 60)).padStart(2, "0")}
<b>:</b>
{String(remaining % 60).padStart(2, "0")}
</output>
<span className="sf-ic-state" aria-live="polite">
{remaining ? (running ? "Counting down" : "Interval") : "Complete"}
</span>
</div>
</div>
<div className="sf-ic-controls">
<div className="sf-ic-presets" aria-label="Interval duration">
{[1, 5, 10].map((n) => (
<button disabled={!active} type="button" key={n} onClick={() => reset(n)} aria-pressed={duration === n}>
{n}
<span>min</span>
</button>
))}
</div>
<div className="sf-ic-actions">
<button
disabled={!active}
type="button"
className="sf-ic-start"
aria-pressed={running}
onClick={() => {
if (!remaining) {
remainingRef.current = duration * 60
setRemaining(duration * 60)
}
setRunning((v) => !v)
}}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
{running ? <path d="M4 3h3v10H4zM9 3h3v10H9z" /> : <path d="m5 3 8 5-8 5z" />}
</svg>
{running ? "Pause" : remaining ? "Start" : "Restart"}
</button>
<button
disabled={!active}
type="button"
className="sf-ic-reset"
aria-label="Reset interval"
onClick={() => reset()}
>
<svg viewBox="0 0 20 20" aria-hidden="true" fill="none">
<path d="M4 8a6 6 0 1 1 0 5M4 3v5h5" stroke="currentColor" strokeWidth="1.5" />
</svg>
</button>
</div>
</div>
</div>
<style>{`.sf-interval-clock{width:100%;height:100%;display:grid;place-items:center;overflow:hidden;background:#d7ded6;color:#263c34;font:12px Arial,sans-serif}.sf-interval-clock *{box-sizing:border-box}.sf-ic-instrument{display:flex;align-items:center;gap:30px;padding:24px 29px 24px 22px;border-radius:40px;background:linear-gradient(125deg,#f0f1e9,#dce0d5 60%,#c7cfc2);border:1px solid #eff2e7;box-shadow:0 2px 1px #7c8b7755,0 13px 20px #4b615122,inset 0 1px 0 #fff8}.sf-ic-face{position:relative;width:252px;height:252px;border-radius:50%;box-shadow:0 3px 4px #63745c55}.sf-ic-face>svg{display:block;width:100%;height:100%}.sf-ic-display{position:absolute;inset:69px 35px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:13px}.sf-ic-display output{font:400 43px/1 Arial,sans-serif;letter-spacing:-2px;font-variant-numeric:tabular-nums;text-shadow:0 1px 0 #fff}.sf-ic-display b{font-weight:400;color:#91a093;padding:0 1px}.sf-ic-lamp{width:6px;height:6px;border-radius:50%;background:#a6b19f;box-shadow:inset 0 1px 1px #24382644,0 1px 0 #fff}.sf-ic-instrument[data-running=true] .sf-ic-lamp{background:#de6436;box-shadow:0 0 6px #df6b4666,inset 0 1px 1px #ffb094}.sf-ic-state{font-size:10px;color:#728171}.sf-ic-controls{width:140px;display:flex;flex-direction:column;gap:39px}.sf-interval-clock button{font:inherit;color:inherit;cursor:pointer;touch-action:manipulation;border:0}.sf-ic-presets{display:flex;padding:4px;border-radius:12px;box-shadow:inset 0 2px 4px #6e7b6b30,0 1px 0 #fff9;background:#cbd2c5}.sf-ic-presets button{min-width:44px;height:55px;background:none;border-radius:9px;font-size:18px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:5px}.sf-ic-presets button span{font-size:9px;color:#758272}.sf-ic-presets button[aria-pressed=true]{background:linear-gradient(#f9faf4,#e1e7d9);box-shadow:0 2px 3px #64725944,inset 0 1px 0 white}.sf-ic-actions{display:flex;gap:9px}.sf-ic-start{height:52px;display:flex;flex:1;align-items:center;justify-content:center;gap:6px;border-radius:12px;background:linear-gradient(#e48152,#d85b2d);box-shadow:0 3px 0 #a84626,0 5px 7px #84633e30,inset 0 1px 0 #ffbc8a;color:#fff5e8!important;transition:transform 100ms,box-shadow 100ms}.sf-ic-start svg{width:14px;height:14px;fill:currentColor}.sf-ic-start:active,.sf-ic-start[aria-pressed=true]{transform:translateY(2px);box-shadow:0 1px 0 #a84626,inset 0 1px 3px #b44727}.sf-ic-reset{height:52px;width:44px;border-radius:12px;background:linear-gradient(#eaf0df,#d0d9c8);box-shadow:0 2px 0 #acbba2,inset 0 1px 0 #fff9}.sf-ic-reset:active{transform:translateY(1px)}.sf-ic-reset svg{width:20px;height:20px}.sf-interval-clock button:focus-visible{outline:2px solid #315e4b;outline-offset:4px}.sf-interval-clock button:disabled{cursor:default;opacity:.6}@media(prefers-reduced-motion:reduce){.sf-interval-clock *{transition:none!important}}`}</style>
</section>
)
}
Where it works well
Focus periods, rehearsal intervals and deliberate timed activities.
Reduced motion
The countdown and ring update in discrete seconds; decorative button transitions are removed.
Things to know
Local browser timer; no alarm, background notification or persisted session. Host pause freezes elapsed time.