Editable Time Label
A time label that expands into a 24-hour format input on edit and collapses into a read-only text badge upon saving.
Live preview and source code for Editable Time Label
"use client"
import { Check, Pencil } from "lucide-react"
import { useState } from "react"
import { AnimatePresence, cubicBezier, motion } from "framer-motion"
export default function TwinInput() {
const [isExpanded, setIsExpanded] = useState(true)
const [time, setTime] = useState({
hr: "",
min: ""
})
const handleTimeChange = (
field: "hr" | "min",
value: string
) => {
let sanitized = value
.replace(/\D/g, "")
.slice(0, 2)
if (sanitized !== "") {
const numericValue = Number(sanitized)
if (field === "hr") {
sanitized = Math.min(numericValue, 24).toString()
}
if (field === "min") {
sanitized = Math.min(numericValue, 59).toString()
}
}
setTime(prev => ({
...prev,
[field]: sanitized
}))
}
return (
<fieldset className={`tracking-normal flex items-center justify-center transition-all duration-400 ease-in-out ${isExpanded ? 'gap-4' : 'gap-0'}`}>
<motion.div
layout
animate={{
borderTopRightRadius: isExpanded ? 12 : 0,
borderBottomRightRadius: isExpanded ? 12 : 0,
paddingRight: isExpanded ? 12 : 0
}}
transition={{ layout: { duration: 0.4 }, ease: cubicBezier(0.76, 0, 0.24, 1) }}
className="flex items-center bg-[#F5F5f5] rounded-tl-xl rounded-bl-xl rounded-xl pl-3 pr-3 gap-0.5 text-black font-semibold">
<motion.div
animate={{ width: isExpanded ? 48 : `${time.hr.length - (0.2 * (time.hr.split("1").length - 1)) || 1}ch` }}
transition={{ duration: 0.4, ease: cubicBezier(0.76, 0, 0.24, 1) }}
className="bg-[#F5F5F5]">
{isExpanded ?
<input
type="text"
inputMode="numeric"
id="twin-input-firstField"
name="hr"
value={time.hr}
onChange={(e) => handleTimeChange("hr", e.target.value)}
placeholder="0"
className="placeholder-black max-w-12 py-3 outline-none border-none appearance-none [moz-appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
:
<div className="py-3">{time.hr || "0"}</div>
}
</motion.div>
<label htmlFor="twin-input-firstField" className="text-[#B1B1B1] font-semibold tracking-normal">Hr.</label>
</motion.div>
<motion.div
layout
animate={{
borderRadius: isExpanded ? 12 : 0,
paddingRight: isExpanded ? 12 : 0,
paddingLeft: isExpanded ? 12 : 6
}}
transition={{ layout: { duration: 0.4 }, ease: cubicBezier(0.76, 0, 0.24, 1) }}
className="flex items-center bg-[#F5F5f5] rounded-xl pl-3 pr-3 gap-0.5 text-black font-semibold">
<motion.div
animate={{ width: isExpanded ? 48 : `${time.min.length - (0.2 * (time.min.split("1").length - 1)) || 1}ch` }}
transition={{ duration: 0.4, ease: cubicBezier(0.76, 0, 0.24, 1) }}
className="bg-[#F5F5F5]">
{isExpanded ?
<input
type="number"
id="twin-input-secondField"
name="min"
value={time.min}
onChange={(e) => handleTimeChange("min", e.target.value)}
placeholder="0"
className="placeholder-black max-w-12 py-3 outline-none border-none appearance-none [moz-appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
:
<div className="py-3">{time.min || "0"}</div>
}
</motion.div>
<label htmlFor="twin-input-secondField" className="text-[#B1B1B1] font-semibold tracking-normal">Min.</label>
</motion.div>
<motion.button
animate={{
borderTopLeftRadius: isExpanded ? 12 : 0,
borderBottomLeftRadius: isExpanded ? 12 : 0
}}
transition={{ duration: 0.4, ease: cubicBezier(0.76, 0, 0.24, 1) }}
onClick={() => setIsExpanded(!isExpanded)}
className="outline-none border-none flex items-center justify-center p-4 rounded-xl bg-[#F5F5f5] cursor-pointer text-[#757575] hover:text-black transition-colors duration-300 ease-in-out">
<AnimatePresence mode="wait" initial={false}>
{isExpanded ? (
<motion.div
key="check"
initial={{ filter: "blur(2px)", opacity: 0.5 }}
animate={{ filter: "blur(0px)", opacity: 1 }}
exit={{ filter: "blur(2px)", opacity: 0.5 }}
transition={{ duration: 0.15 }}
>
<Check size={16} strokeWidth={3} />
</motion.div>
) : (
<motion.div
key="pencil"
initial={{ filter: "blur(2px)", opacity: 0.5 }}
animate={{ filter: "blur(0px)", opacity: 1 }}
exit={{ filter: "blur(2px)", opacity: 0.5 }}
transition={{ duration: 0.15 }}
>
<Pencil size={16} strokeWidth={3} />
</motion.div>
)}
</AnimatePresence>
</motion.button>
</fieldset>
)
}
An inline time field that holds hours and minutes in two small inputs and collapses into a compact read-only label when you confirm it. Editing and display are the same control rather than a field that swaps for text, so the row it sits in never changes height. The width of each number animates to fit its own digits, which is what makes the collapsed state read as a label instead of an empty input.
Installation
Copy the source from the Code tab above into your project as
EditableTimeLabel.tsx.
It imports framer-motion for the width and radius animations and
lucide-react for the check and pencil icons:
npm install framer-motion lucide-reactThis one is styled with Tailwind utility classes, so the copied file needs
Tailwind set up in your project and the file included in your content paths.
Its greys are literal hex values in the class names (bg-[#F5F5f5],
text-[#B1B1B1]), so nothing here reads from your theme: to match your
palette, replace those values in the file.
Usage
It takes no props and manages its own state, so this is the whole of it:
import EditableTimeLabel from "./components/EditableTimeLabel";
export default function Example() {
return <EditableTimeLabel />;
}It starts in the expanded, editable state. Nothing outside the component can read the hours and minutes, so putting it in a real form means lifting that state out of the copied file, which the "When not to" section below covers.
When to reach for it
A duration in a settings row: a reminder offset, a session length, a cooking
timer. The Hr. and Min. labels are fixed, so it reads as an amount of time
rather than a clock time.
A field in a dense list where a full date and time picker would dominate the row, and the only thing being edited is a number of hours and minutes.
A form where the saved value should still look editable. The pencil icon on the collapsed state is the affordance that tells someone the label is a control.
When not to
If you are picking a time of day, use <input type="time">. It gives you
the platform picker, meridiem handling, locale formatting and validation for
free, none of which is here.
If you need the value. The component owns its state and exposes no props
and no change callback, so nothing outside it can read the hours and minutes.
Using it in a real form means editing the copied file to lift the state or add
an onChange. That is a deliberate starting point, not an oversight, but it is
work you have to do.
If the value must be validated. Input is clamped as you type and never rejected, so there is no error state, no required handling and no message to show when something is wrong.
Accessibility
Both inputs are labelled: Hr. and Min. are real <label> elements tied to
the inputs by htmlFor, so each field is announced with its unit.
The confirm button is icon-only and has no accessible name. A screen reader
announces it as an unlabelled button, and the icon swap between the check and
the pencil is not announced at all. Add an aria-label that reflects the
current state, and consider aria-expanded, before shipping it.
The collapsed state renders the numbers as plain text in a <div>, so the
value stays readable, but nothing tells assistive technology that the control
changed mode.
Notes
The confirm button has no type attribute, so inside a <form> it defaults to
type="submit" and will submit on click. Add type="button" when you drop it
into a form.
Hours are clamped to a maximum of 24 and minutes to 59, and anything that is
not a digit is stripped as you type. Both fields accept two characters, and
24 is reachable, so clamp to 23 in the copied file if you are treating the
value as a clock time.
The two fields are not symmetrical in the source: hours is a text input with
inputMode="numeric" and minutes is a number input with its spinners hidden.
Both behave the same when typed into.
The collapsed width is computed in ch units with a small correction for the
digit 1, which assumes the surrounding font is roughly monospaced in its
digits. A font with unusually narrow or wide numerals will make the collapsed
label sit slightly off.