Forms
Day Schedule
A vertical 24-hour axis with named time spans in parallel lanes. Drag a span to move it or either end to resize, snapping to a configurable step with a minimum duration, fully keyboard operable.
scheduletimerangeplannerdragcalendarday
Default
Drag a bar to move a span, or either end node to resize it. Snaps to 15 minutes with a 30-minute minimum.
wed 20 aug
snaps to 15 mindeep work
review
lunch
pairing
deploy window
Custom step and height
Hour-level snapping with a two-hour minimum on a shorter track.
morning shift
evening shift
Controlled with derived output
Because it is controlled, the total scheduled time updates as you drag.
deep work
review
lunch
3 spans — 06:30 scheduled
Read-only
Renders the schedule without drag handles or keyboard adjustment.
deep work
review
lunch
pairing
deploy window
Disabled
Non-interactive and dimmed.
deep work
review
lunch
Source
Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { useCallback, useRef, useState } from "react"
import { cn } from "@/lib/utils"
/**
* DaySchedule — quebi design system
*
* A vertical 24-hour day axis with named time spans laid out in parallel lanes.
* Each span can be dragged to move and resized from either end, snapping to a
* configurable step with a minimum duration. Fully keyboard operable: the bar
* and both handles expose `role="slider"` with arrow-key adjustment.
*
* Spans are minutes from midnight (0–1440), so the component stays free of any
* date library. Supply `spans` + `onSpansChange` for controlled use, or
* `defaultSpans` to let it manage its own state.
*/
const DAY_MINUTES = 1440
export type DayScheduleTone = "brand" | "cyan"
export interface DaySpan {
/** Stable identity — used as the React key and in change callbacks. */
id: string
label: string
/** Minutes from midnight, 0–1440. */
start: number
/** Minutes from midnight, 0–1440. Always greater than `start`. */
end: number
/** Accent color. Defaults to alternating brand/cyan by lane order. */
tone?: DayScheduleTone
}
const TONES: Record<DayScheduleTone, { bar: string; node: string }> = {
brand: {
bar: "bg-quebi-brand shadow-[0_0_12px_rgb(45_212_168/0.35)]",
node: "border-quebi-brand",
},
cyan: {
bar: "bg-cyan-500 shadow-[0_0_12px_rgb(6_182_212/0.35)]",
node: "border-cyan-500",
},
}
/** `540` → `"09:00"`. */
export function formatDayTime(minutes: number) {
const h = Math.floor(minutes / 60)
const m = Math.floor(minutes % 60)
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
}
const toPercent = (minutes: number) => `${((minutes / DAY_MINUTES) * 100).toFixed(4)}%`
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
type DragPart = "body" | "start" | "end"
export interface DayScheduleProps extends Omit<React.ComponentProps<"div">, "onChange"> {
/** Controlled spans. Pair with `onSpansChange`. */
spans?: DaySpan[]
/** Initial spans for uncontrolled use. */
defaultSpans?: DaySpan[]
onSpansChange?: (spans: DaySpan[]) => void
/** Snap increment in minutes. */
step?: number
/** Shortest allowed span in minutes. */
minDuration?: number
/** Gap between axis labels and gridlines, in minutes. */
tickInterval?: number
/** Track height in pixels. */
height?: number
/** Horizontal distance between lanes, in pixels. */
laneGap?: number
/** Offset of the first lane from the track's left edge, in pixels. */
laneOffset?: number
/** Render the rotated start/end time labels beside each span. */
showTimeLabels?: boolean
isDisabled?: boolean
isReadOnly?: boolean
/** Override how a minute offset is rendered as a time. */
formatTime?: (minutes: number) => string
}
export function DaySchedule({
spans: controlledSpans,
defaultSpans = [],
onSpansChange,
step = 15,
minDuration = 30,
tickInterval = 120,
height = 560,
laneGap = 18,
laneOffset = 24,
showTimeLabels = true,
isDisabled = false,
isReadOnly = false,
formatTime = formatDayTime,
className,
...props
}: DayScheduleProps) {
const [uncontrolled, setUncontrolled] = useState<DaySpan[]>(defaultSpans)
const isControlled = controlledSpans !== undefined
const spans = isControlled ? controlledSpans : uncontrolled
const trackRef = useRef<HTMLDivElement>(null)
const interactive = !isDisabled && !isReadOnly
const commit = useCallback(
(next: DaySpan[]) => {
if (!isControlled) setUncontrolled(next)
onSpansChange?.(next)
},
[isControlled, onSpansChange],
)
// The pointermove listener is attached once per drag, so it would otherwise
// close over the spans array from the render that started the drag. Reading
// through a ref keeps every move applied against current state.
const spansRef = useRef(spans)
spansRef.current = spans
/** Apply a start/end delta to one span, honouring snapping and the day bounds. */
const applyMove = useCallback(
(index: number, part: DragPart, minute: number, origin: DaySpan) => {
const next = spansRef.current.map((span, i) => {
if (i !== index) return span
if (part === "start") {
return { ...span, start: clamp(minute, 0, span.end - minDuration) }
}
if (part === "end") {
return { ...span, end: clamp(minute, span.start + minDuration, DAY_MINUTES) }
}
const length = origin.end - origin.start
const start = clamp(minute, 0, DAY_MINUTES - length)
return { ...span, start, end: start + length }
})
commit(next)
},
[minDuration, commit],
)
const minuteFromClientY = useCallback(
(clientY: number) => {
const track = trackRef.current
if (!track) return 0
const rect = track.getBoundingClientRect()
const ratio = clamp((clientY - rect.top) / rect.height, 0, 1)
return Math.round((ratio * DAY_MINUTES) / step) * step
},
[step],
)
const startDrag = (
event: React.PointerEvent<HTMLElement>,
index: number,
part: DragPart,
) => {
if (!interactive || event.button !== 0) return
event.preventDefault()
const target = event.currentTarget
const origin = spans[index]
const grabbedAt = minuteFromClientY(event.clientY)
target.setPointerCapture(event.pointerId)
const handleMove = (moveEvent: PointerEvent) => {
const minute = minuteFromClientY(moveEvent.clientY)
// Body drags translate by the delta from the grab point so the span does
// not jump to centre itself under the cursor.
const value = part === "body" ? origin.start + (minute - grabbedAt) : minute
applyMove(index, part, value, origin)
}
const handleUp = () => {
target.releasePointerCapture?.(event.pointerId)
target.removeEventListener("pointermove", handleMove)
target.removeEventListener("pointerup", handleUp)
target.removeEventListener("pointercancel", handleUp)
}
target.addEventListener("pointermove", handleMove)
target.addEventListener("pointerup", handleUp)
target.addEventListener("pointercancel", handleUp)
}
const handleKeyDown = (
event: React.KeyboardEvent<HTMLElement>,
index: number,
part: DragPart,
) => {
if (!interactive) return
const span = spans[index]
// Shift jumps a full tick (an hour at the default interval) for coarse moves.
const delta = event.shiftKey ? tickInterval : step
let direction = 0
if (event.key === "ArrowUp" || event.key === "ArrowLeft") direction = -1
if (event.key === "ArrowDown" || event.key === "ArrowRight") direction = 1
if (direction !== 0) {
event.preventDefault()
const base = part === "end" ? span.end : span.start
applyMove(index, part, base + direction * delta, span)
return
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault()
const edge = event.key === "Home" ? 0 : DAY_MINUTES
applyMove(index, part, edge, span)
}
}
const ticks = Math.floor(DAY_MINUTES / tickInterval)
// Push the name column clear of the widest lane so labels never overlap bars.
const labelOffset = laneOffset + Math.max(0, spans.length - 1) * laneGap + 28
return (
<div
className={cn(
"flex w-full gap-2.5 font-sans select-none",
isDisabled && "pointer-events-none opacity-50",
className,
)}
{...props}
>
{/* Hour axis */}
<div
className="relative w-10 flex-none border-r border-quebi-line/10"
style={{ height }}
aria-hidden="true"
>
{Array.from({ length: ticks + 1 }, (_, i) => (
<div
key={i}
className="absolute left-0 -translate-y-1/2 text-[9.5px] text-quebi-fg-subtle tabular-nums"
style={{ top: toPercent(i * tickInterval) }}
>
{i === ticks ? "24:00" : formatTime(i * tickInterval)}
</div>
))}
</div>
{/* Span track */}
<div ref={trackRef} className="relative flex-1" style={{ height }}>
{Array.from({ length: ticks + 1 }, (_, i) => (
<div
key={i}
aria-hidden="true"
className="absolute inset-x-0 h-px bg-quebi-line/[0.06]"
style={{ top: toPercent(i * tickInterval) }}
/>
))}
{spans.map((span, index) => {
const tone = TONES[span.tone ?? (index % 2 === 0 ? "brand" : "cyan")]
const lane = `${laneOffset + index * laneGap}px`
const valueText = `${span.label}, ${formatTime(span.start)} to ${formatTime(span.end)}`
return (
<div key={span.id} role="group" aria-label={span.label}>
{/* Body — drag to move the whole span */}
<div
role="slider"
tabIndex={interactive ? 0 : -1}
aria-label={`${span.label} span`}
aria-valuemin={0}
aria-valuemax={DAY_MINUTES}
aria-valuenow={span.start}
aria-valuetext={valueText}
aria-disabled={isDisabled || undefined}
aria-readonly={isReadOnly || undefined}
onPointerDown={(e) => startDrag(e, index, "body")}
onKeyDown={(e) => handleKeyDown(e, index, "body")}
className={cn(
"absolute w-[5px] -translate-x-1/2 rounded-[3px] outline-hidden",
"touch-none transition-shadow duration-150",
tone.bar,
interactive ? "cursor-grab active:cursor-grabbing" : "cursor-default",
"focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
)}
style={{
left: lane,
top: toPercent(span.start),
height: toPercent(span.end - span.start),
}}
/>
{/* Start / end handles */}
{(["start", "end"] as const).map((part) => (
<div
key={part}
role="slider"
tabIndex={interactive ? 0 : -1}
aria-label={`${span.label} ${part} time`}
aria-valuemin={0}
aria-valuemax={DAY_MINUTES}
aria-valuenow={span[part]}
aria-valuetext={formatTime(span[part])}
aria-disabled={isDisabled || undefined}
aria-readonly={isReadOnly || undefined}
onPointerDown={(e) => startDrag(e, index, part)}
onKeyDown={(e) => handleKeyDown(e, index, part)}
className={cn(
"absolute size-[11px] -translate-x-1/2 -translate-y-1/2 rounded-full",
"border-2 bg-quebi-bg outline-hidden touch-none",
"transition-transform duration-150",
tone.node,
interactive ? "cursor-ns-resize hover:scale-110" : "cursor-default",
"focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
)}
style={{ left: lane, top: toPercent(span[part]) }}
/>
))}
{/* Name */}
<div
className="absolute -translate-y-1/2 whitespace-nowrap text-xs text-quebi-fg-muted"
style={{
left: `${labelOffset}px`,
top: toPercent((span.start + span.end) / 2),
}}
>
{span.label}
</div>
{/* Rotated edge times */}
{showTimeLabels && (
<>
<div
aria-hidden="true"
className="absolute origin-top-left whitespace-nowrap text-[10.5px] text-quebi-fg-muted tabular-nums"
style={{
left: lane,
top: toPercent(span.start),
transform: "rotate(-90deg) translate(16px, -50%)",
}}
>
{formatTime(span.start)}
</div>
<div
aria-hidden="true"
className="absolute origin-top-left whitespace-nowrap text-[10.5px] text-quebi-fg-muted tabular-nums"
style={{
left: lane,
top: toPercent(span.end),
transform: "rotate(-90deg) translate(calc(-100% - 16px), -50%)",
}}
>
{formatTime(span.end)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}