Make room in the session
Moving or resizing either end of a session shifts its neighbors in both directions while preserving minimum durations and changeover time.

Drag a block to move it or either edge to resize. Focus a block or edge and use arrow keys in five-minute steps; Escape cancels the drag.
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 * as React from "react"
export const DEFAULT_CONFIG = { eventCount: 4, gapMinutes: 10, dayEnd: 1080, settleMs: 160 }
type Config = typeof DEFAULT_CONFIG
export type Session = { start: number; duration: number }
export type EditMode = "move" | "start" | "end"
const NAMES = ["Soundcheck", "Live take", "Overdubs", "Mix review", "Stems", "Playback"]
const COLORS = ["#ff7049", "#bfcaf2", "#eee9dd", "#c1ccae", "#f1b84c", "#9eafbb"]
const START = 720
const MINIMUM = 15
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, Number.isFinite(v) ? v : min))
const snap = (v: number) => Math.round(v / 5) * 5
const time = (n: number) => `${String(Math.floor(n / 60)).padStart(2, "0")}:${String(n % 60).padStart(2, "0")}`
function bounds(base: Session[], index: number, gap: number, end: number) {
return {
start: START + base.slice(0, index).reduce((sum, item) => sum + item.duration + gap, 0),
end: end - base.slice(index + 1).reduce((sum, item) => sum + item.duration + gap, 0),
}
}
export function editSchedule(
base: Session[],
index: number,
mode: EditMode,
value: number,
gap: number,
end: number,
): Session[] {
const next = base.map((item) => ({ ...item }))
const item = next[index]
const limits = bounds(base, index, gap, end)
if (mode === "move") {
item.start = clamp(snap(value), limits.start, limits.end - item.duration)
} else if (mode === "start") {
const finish = item.start + item.duration
item.start = clamp(snap(value), limits.start, finish - MINIMUM)
item.duration = finish - item.start
} else {
item.duration = clamp(snap(value), item.start + MINIMUM, limits.end) - item.start
}
for (let i = index - 1; i >= 0; i--) {
const adjacent = base[i + 1].start - base[i].start - base[i].duration <= gap
const latest = next[i + 1].start - gap - next[i].duration
next[i].start = adjacent ? latest : Math.min(base[i].start, latest)
}
for (let i = index + 1; i < next.length; i++) {
const adjacent = base[i].start - base[i - 1].start - base[i - 1].duration <= gap
const earliest = next[i - 1].start + next[i - 1].duration + gap
next[i].start = adjacent ? earliest : Math.max(base[i].start, earliest)
}
return next
}
export function ripple(base: Session[], index: number, duration: number, gap: number, end: number) {
return editSchedule(base, index, "end", base[index].start + duration, gap, end)
}
export function fixture(count: number, gap: number, end: number) {
const weights = [85, 55, 70, 50, 35, 30].slice(0, count)
const spare = Math.min(15, Math.floor((end - START - gap * (count - 1) - MINIMUM * count) / 10) * 5)
const available = end - START - gap * (count - 1) - spare * 2
const factor = Math.min(1, (available - MINIMUM * count) / weights.reduce((sum, value) => sum + value - MINIMUM, 0))
let cursor = START + spare
return weights.map((value) => {
const duration = MINIMUM + Math.floor(((value - MINIMUM) * factor) / 5) * 5
const item = { start: cursor, duration }
cursor += duration + gap
return item
})
}
function useFittedStage() {
const viewport = React.useRef<HTMLDivElement>(null)
const [scale, setScale] = React.useState(1)
React.useLayoutEffect(() => {
const element = viewport.current!
const fit = () => setScale(Math.min(element.clientWidth / 600, element.clientHeight / 375))
fit()
const observer = new ResizeObserver(fit)
observer.observe(element)
return () => observer.disconnect()
}, [])
return { viewport, scale }
}
export default function Example({ active = true, config }: { active?: boolean; config?: Partial<Config> }) {
const fitted = useFittedStage()
const settings = { ...DEFAULT_CONFIG, ...config }
const count = Math.round(clamp(settings.eventCount, 3, 6)),
gap = snap(clamp(settings.gapMinutes, 0, 30)),
end = snap(clamp(settings.dayEnd, 960, 1200))
const [sessions, setSessions] = React.useState(() => fixture(count, gap, end))
const [original, setOriginal] = React.useState<Session[] | null>(null)
const [selected, setSelected] = React.useState<number | null>(null)
const [keyboard, setKeyboard] = React.useState(true)
const track = React.useRef<HTMLDivElement>(null)
const drag = React.useRef<{
index: number
mode: EditMode
x: number
base: Session[]
owner: HTMLButtonElement
id: number
width: number
} | null>(null)
const cancel = React.useCallback(() => {
const previous = drag.current
drag.current = null
if (previous) {
setSessions(previous.base)
if (previous.owner.hasPointerCapture(previous.id)) {
previous.owner.releasePointerCapture(previous.id)
}
}
setOriginal(null)
setSelected(null)
setKeyboard(true)
}, [])
React.useEffect(() => {
cancel()
setSessions(fixture(count, gap, end))
}, [count, gap, end, cancel])
React.useEffect(() => {
if (!active) {
cancel()
}
}, [active, cancel])
React.useEffect(() => {
const hidden = () => {
if (document.hidden) {
cancel()
}
}
window.addEventListener("blur", cancel)
document.addEventListener("visibilitychange", hidden)
return () => {
window.removeEventListener("blur", cancel)
document.removeEventListener("visibilitychange", hidden)
}
}, [cancel])
const current = sessions.length === count ? sessions : fixture(count, gap, end)
const span = end - START
const finish = (event: React.PointerEvent<HTMLButtonElement>) => {
drag.current = null
setOriginal(null)
setSelected(null)
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
function interaction(index: number, mode: EditMode) {
const item = current[index]
const limits = bounds(current, index, gap, end)
const value = mode === "end" ? item.start + item.duration : item.start
const min = mode === "end" ? item.start + MINIMUM : limits.start
const max =
mode === "end" ? limits.end : mode === "start" ? item.start + item.duration - MINIMUM : limits.end - item.duration
return {
role: "slider" as const,
"aria-label": `${NAMES[index]} ${mode === "move" ? "position" : mode === "start" ? "start" : "end"}`,
"aria-valuemin": min,
"aria-valuemax": max,
"aria-valuenow": value,
"aria-valuetext": `${time(item.start)} to ${time(item.start + item.duration)}, ${item.duration} minutes`,
disabled: !active,
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0 || drag.current) {
return
}
event.preventDefault()
event.currentTarget.focus()
setKeyboard(false)
setSelected(index)
const base = current.map((s) => ({ ...s }))
setOriginal(base)
drag.current = {
index,
mode,
x: event.clientX,
base,
owner: event.currentTarget,
id: event.pointerId,
width: track.current!.getBoundingClientRect().width,
}
event.currentTarget.setPointerCapture(event.pointerId)
},
onPointerMove: (event: React.PointerEvent<HTMLButtonElement>) => {
const d = drag.current
if (!d || d.id !== event.pointerId) {
return
}
const item = d.base[d.index]
const value =
(d.mode === "end" ? item.start + item.duration : item.start) + ((event.clientX - d.x) / d.width) * span
setSessions(editSchedule(d.base, d.index, d.mode, value, gap, end))
},
onPointerUp: finish,
onPointerCancel: cancel,
onLostPointerCapture: () => {
if (drag.current) {
cancel()
}
},
onBlur: () => {
if (drag.current) {
cancel()
}
},
onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
return
}
event.preventDefault()
setKeyboard(true)
const next =
event.key === "Home"
? min
: event.key === "End"
? max
: value + (["ArrowLeft", "ArrowDown"].includes(event.key) ? -5 : 5)
setSessions(editSchedule(current, index, mode, next, gap, end))
},
}
}
return (
<div
ref={fitted.viewport}
style={{
width: "100%",
height: "100%",
aspectRatio: "16 / 10",
position: "relative",
overflow: "hidden",
background: "#1d2226",
}}
>
<div
style={{
width: 600,
height: 375,
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${fitted.scale})`,
transformOrigin: "center",
}}
>
<div
className="schedule-ripple"
data-sessions={JSON.stringify(current)}
data-keyboard={keyboard}
data-active={active}
onKeyDownCapture={() => setKeyboard(true)}
style={
{ "--response": `${active && !keyboard ? clamp(settings.settleMs, 0, 240) : 0}ms` } as React.CSSProperties
}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
cancel()
}
}}
>
<style>{`
.schedule-ripple{height:100%;width:100%;background:#1d2226;color:#eff0e9;font-family:Arial,Helvetica,sans-serif;display:grid;place-items:center;box-sizing:border-box;-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}.schedule-ripple *{box-sizing:border-box}.sr-sheet{width:536px}.sr-hours{display:flex;justify-content:space-between;margin-left:100px;height:28px;font:10px monospace;color:#9ba3a7}.sr-row{height:62px;display:grid;grid-template-columns:100px minmax(0,1fr);align-items:center}.sr-label{font-size:10px;color:#c3c8c9;line-height:1.4;position:relative;padding-left:27px}.sr-label b{position:absolute;left:0;top:0;font:17px/1 Arial,sans-serif;letter-spacing:-.8px;color:#eee}.sr-label small{display:block;font:9px/1.4 monospace;color:#788489;margin-top:3px}.sr-track{height:50px;position:relative;background:repeating-linear-gradient(90deg,#ffffff0b 0 1px,transparent 1px 25%)}.sr-block{position:absolute;inset:0;pointer-events:none;transition:transform var(--response) cubic-bezier(.22,.68,0,1)}.sr-face{position:absolute;inset:0;transform-origin:left;border-radius:3px;background:var(--color);box-shadow:inset 0 1px #ffffff50,0 3px 4px #00000016}.sr-block[data-selected=true]{z-index:3;transition:none}.sr-block[data-selected=true] .sr-face{box-shadow:inset 0 0 0 1px #ffffffa0,0 3px 10px #0003}.sr-ghost{position:absolute;inset:0;border:1px dashed #cbd5df55;pointer-events:none}.schedule-ripple button{appearance:none;border:0;margin:0;padding:0;position:absolute;top:0;height:100%;color:#162027;background:none;pointer-events:auto;touch-action:none}.sr-move{left:0;cursor:grab;overflow:hidden;display:grid;place-items:center;font:20px Arial,sans-serif;letter-spacing:-1px;white-space:nowrap}.sr-move:active{cursor:grabbing}.sr-move span{position:relative}.sr-move:before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 5px,#17212c0b 5px 6px);opacity:.5}.sr-resize{width:20px;cursor:ew-resize;z-index:2;transform:translateX(-50%)}.sr-resize:before{content:"";position:absolute;top:15px;bottom:15px;left:9px;width:2px;border-radius:1px;background:#17212c80}.schedule-ripple[data-keyboard=false] button:focus{outline:none}.schedule-ripple[data-keyboard=true] button:focus-visible{outline:2px solid #fff;outline-offset:3px;border-radius:3px;z-index:4}.schedule-ripple button:disabled{cursor:default}.sr-footer{display:flex;justify-content:space-between;margin-top:16px;padding-top:12px;border-top:1px solid #ffffff16;color:#9ba3a7;font:10px monospace}.sr-footer output{color:#dde2df}.sr-sheet[data-count="5"] .sr-row,.sr-sheet[data-count="6"] .sr-row{height:46px}.sr-sheet[data-count="5"] .sr-track,.sr-sheet[data-count="6"] .sr-track{height:36px}.sr-sheet[data-count="5"] .sr-resize:before,.sr-sheet[data-count="6"] .sr-resize:before{top:10px;bottom:10px}.sr-sheet[data-count="6"] .sr-move{font-size:15px}.schedule-ripple[data-keyboard=true] *,.schedule-ripple[data-active=false] *{transition:none!important}@media(prefers-reduced-motion:reduce){.schedule-ripple *{transition:none!important}}@media(hover:hover){.sr-resize:hover:before{background:#fff}}
`}</style>
<section className="sr-sheet" data-count={count} aria-label="Recording studio schedule">
<div className="sr-hours">
{Array.from({ length: 5 }, (_, i) => (
<span key={i}>{time(snap(START + (span * i) / 4))}</span>
))}
</div>
<div className="sr-rows">
{current.map((session, i) => {
const old = original?.[i]
return (
<div className="sr-row" key={NAMES[i]}>
<div className="sr-label">
<b>{String(i + 1).padStart(2, "0")}</b>
{NAMES[i]}
<small>{time(session.start)}</small>
</div>
<div className="sr-track" ref={i === 0 ? track : undefined}>
{old && (
<div
className="sr-ghost"
style={{
width: `${(old.duration / span) * 100}%`,
transform: `translateX(${((old.start - START) / old.duration) * 100}%)`,
}}
/>
)}
<div
className="sr-block"
data-selected={selected === i}
data-shifted={!!old && old.start !== session.start}
style={
{
"--color": COLORS[i],
transform: `translateX(${((session.start - START) / span) * 100}%)`,
} as React.CSSProperties
}
>
<div className="sr-face" style={{ transform: `scaleX(${session.duration / span})` }} />
<button
type="button"
className="sr-move"
{...interaction(i, "move")}
style={{ width: `${(session.duration / span) * 100}%` }}
>
<span>{session.duration >= 35 ? `${session.duration}′` : ""}</span>
</button>
<button
type="button"
className="sr-resize sr-start"
{...interaction(i, "start")}
style={{ left: 0 }}
/>
<button
type="button"
className="sr-resize sr-end"
{...interaction(i, "end")}
style={{ left: `${(session.duration / span) * 100}%` }}
/>
</div>
</div>
</div>
)
})}
</div>
<div className="sr-footer">
<span>{gap} min changeover</span>
<output aria-live="polite">
{selected !== null
? `${time(current[selected].start)} — ${time(current[selected].start + current[selected].duration)}`
: `Wrap ${time(current.at(-1)!.start + current.at(-1)!.duration)}`}
</output>
</div>
</section>
</div>
</div>
</div>
)
}
Where it works well
Edit a recording schedule or production timeline with collision handling and reversible ripple changes.
Reduced motion
Resizing the active block is immediate. Neighboring transform settling is disabled for keyboard input, reduced motion and inactive hosts. Pausing cancels the drag.
Things to know
Local one-room schedule with five-minute steps and a fixed session order. Changes clamp to available space. Touching neighbors follow in either direction; free gaps absorb changes before distant blocks move. Changing count, gap or closing time resets the fixture.