Subframe
Components & interactionsDrag-to-reorder list

Queue reorder

A lifted track follows the pointer while neighboring tracks move into their new slots before release.

Drag a handle across rows or use Up/Down, Home and End. Escape cancels an in-progress 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"
const TRACKS = [
  { id: "morning", title: "A slow morning", artist: "Soft Signal", duration: "3:42", color: "#dfae70" },
  { id: "streets", title: "Side streets", artist: "North Parade", duration: "4:08", color: "#8baebb" },
  { id: "blue", title: "Blue hour", artist: "Kiri", duration: "2:57", color: "#c37b61" },
]
const STEP = 68
function reorder(items: string[], id: string, index: number) {
  const next = items.filter((v) => v !== id)
  next.splice(index, 0, id)
  return next
}
function Cover({ index }: { index: number }) {
  return (
    <svg viewBox="0 0 48 48" aria-hidden>
      {index === 0 ? (
        <>
          <rect width="48" height="48" fill="#d1a566" />
          {Array.from({ length: 6 }, (_, i) => (
            <ellipse
              key={i}
              cx="24"
              cy="24"
              rx="7"
              ry="20"
              fill="none"
              stroke="#6e332c"
              strokeWidth="2"
              transform={`rotate(${i * 30} 24 24)`}
            />
          ))}
          <circle cx="24" cy="24" r="4" fill="#5b2523" />
        </>
      ) : index === 1 ? (
        <>
          <rect width="48" height="48" fill="#a6c3c6" />
          {Array.from({ length: 6 }, (_, i) => (
            <path
              key={i}
              d={`M${i * 11 - 18} -8v30q0 6 6 6h8q6 0 6 6v23`}
              stroke="#215568"
              strokeWidth="3"
              fill="none"
            />
          ))}
        </>
      ) : (
        <>
          <rect width="48" height="48" fill="#ca7559" />
          <path d="M4 39 24 5l20 34z" fill="#332c35" />
          <circle cx="24" cy="29" r="10" fill="#e9b59a" />
          <path d="M4 39h40" stroke="#f0c5a7" strokeWidth="2" />
        </>
      )}
    </svg>
  )
}
export default function Example({ active = true }: { active?: boolean }) {
  const [order, setOrder] = React.useState(TRACKS.map((t) => t.id)),
    [drag, setDrag] = React.useState<{ id: string; y: number } | null>(null),
    [keyboard, setKeyboard] = React.useState(false),
    [announcement, setAnnouncement] = React.useState("")
  const orderRef = React.useRef(order),
    list = React.useRef<HTMLDivElement>(null)
  const gesture = React.useRef<{
    id: string
    pointer: number
    startY: number
    origin: number
    scale: number
    original: string[]
  } | null>(null)
  const update = (next: string[]) => {
    orderRef.current = next
    setOrder(next)
  }
  const finish = React.useCallback((cancel: boolean) => {
    const g = gesture.current
    if (!g) return
    gesture.current = null
    if (cancel) {
      orderRef.current = g.original
      setOrder(g.original)
    } else
      setAnnouncement(
        `${TRACKS.find((t) => t.id === g.id)?.title} moved to position ${orderRef.current.indexOf(g.id) + 1}`,
      )
    setDrag(null)
    if (list.current?.hasPointerCapture(g.pointer)) list.current.releasePointerCapture(g.pointer)
  }, [])
  React.useEffect(() => {
    if (!active) finish(true)
  }, [active, finish])
  React.useEffect(() => {
    const blur = () => finish(true)
    window.addEventListener("blur", blur)
    return () => window.removeEventListener("blur", blur)
  }, [finish])
  return (
    <section
      className="sf-queue-reorder"
      data-still={!active || keyboard}
      onKeyDownCapture={(e) => {
        setKeyboard(true)
        if (e.key === "Escape") {
          e.preventDefault()
          finish(true)
        }
      }}
      onPointerDownCapture={() => setKeyboard(false)}
      aria-label="Reorder listening queue"
    >
      <div className="queue">
        <header>
          <div>
            <span className="queue-icon" aria-hidden>
              <svg viewBox="0 0 20 20">
                <path d="M3 5h14M3 10h14M3 15h8" />
                <path d="m14 13 4 3-4 3z" fill="currentColor" stroke="none" />
              </svg>
            </span>
            <strong>Up next</strong>
          </div>
          <span>3 tracks · 10:47</span>
        </header>
        <div
          className="tracks"
          ref={list}
          role="list"
          aria-label="Listening queue"
          onPointerMove={(e) => {
            const g = gesture.current
            if (!g || e.pointerId !== g.pointer) return
            const y = Math.max(0, Math.min(STEP * 2, g.origin + (e.clientY - g.startY) * g.scale))
            setDrag({ id: g.id, y })
            const target = Math.round(y / STEP)
            if (target !== orderRef.current.indexOf(g.id)) update(reorder(orderRef.current, g.id, target))
          }}
          onPointerUp={(e) => {
            if (e.pointerId === gesture.current?.pointer) finish(false)
          }}
          onPointerCancel={(e) => {
            if (e.pointerId === gesture.current?.pointer) finish(true)
          }}
          onLostPointerCapture={(e) => {
            if (e.pointerId === gesture.current?.pointer) finish(true)
          }}
        >
          {order.map((id, i) => {
            const index = TRACKS.findIndex((t) => t.id === id),
              t = TRACKS[index]
            return (
              <div
                key={id}
                role="listitem"
                className="track"
                data-id={id}
                data-dragging={drag?.id === id}
                style={{
                  transform: `translateY(${drag?.id === id ? drag.y : i * STEP}px)`,
                  zIndex: drag?.id === id ? 2 : 1,
                }}
              >
                <span className="number" aria-hidden>
                  {i + 1}
                </span>
                <span className="cover">
                  <Cover index={index} />
                </span>
                <div className="track-label">
                  <strong>{t.title}</strong>
                  <small>{t.artist}</small>
                </div>
                <span className="duration">{t.duration}</span>
                <button
                  type="button"
                  disabled={!active}
                  className="grip"
                  aria-label={`Move ${t.title}, use up and down arrows`}
                  onKeyDown={(e) => {
                    if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
                      e.preventDefault()
                      const target =
                        e.key === "Home"
                          ? 0
                          : e.key === "End"
                          ? 2
                          : Math.max(0, Math.min(2, i + (e.key === "ArrowDown" ? 1 : -1)))
                      update(reorder(orderRef.current, id, target))
                      setAnnouncement(`${t.title} moved to position ${target + 1}`)
                    }
                  }}
                  onPointerDown={(e) => {
                    if (!active || gesture.current || e.button !== 0 || !list.current) return
                    e.preventDefault()
                    const r = list.current.getBoundingClientRect()
                    gesture.current = {
                      id,
                      pointer: e.pointerId,
                      startY: e.clientY,
                      origin: i * STEP,
                      scale: list.current.offsetHeight / r.height,
                      original: [...orderRef.current],
                    }
                    list.current.setPointerCapture(e.pointerId)
                    e.currentTarget.focus({ preventScroll: true })
                    setDrag({ id, y: i * STEP })
                  }}
                >
                  <svg viewBox="0 0 16 20" aria-hidden>
                    {[5, 10, 15].flatMap((y) => [5, 11].map((x) => <circle key={`${x}-${y}`} cx={x} cy={y} r="1" />))}
                  </svg>
                </button>
              </div>
            )
          })}
        </div>
        <div className="sr" role="status">
          {announcement}
        </div>
      </div>
      <style>{`
.sf-queue-reorder{width:100%;height:100%;display:grid;place-items:center;background:#202122;color:#e9e8e4;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}.sf-queue-reorder *{box-sizing:border-box}.sf-queue-reorder button{font:inherit;color:inherit;cursor:grab;touch-action:none}.sf-queue-reorder button:disabled{cursor:default}.sf-queue-reorder :focus-visible{outline:2px solid #d2c79c;outline-offset:-4px;border-radius:7px}.sf-queue-reorder .queue{width:450px;background:linear-gradient(#343638,#2f3132);border:1px solid #4a4c4d;border-radius:15px;padding:0 14px 16px;box-shadow:inset 0 1px 0 #ffffff09,0 18px 36px -12px #0006}.sf-queue-reorder header{display:flex;align-items:center;justify-content:space-between;padding:0 7px;height:59px;border-bottom:1px solid #ffffff0b;margin-bottom:10px}.sf-queue-reorder header>div{display:flex;align-items:center;gap:9px}.sf-queue-reorder header strong{font-size:12px;font-weight:500}.sf-queue-reorder header>span{font-size:10px;color:#929593}.sf-queue-reorder header svg{width:16px;height:16px;fill:none;stroke:#aeb3ad;stroke-width:1.25;stroke-linecap:round}.sf-queue-reorder .tracks{height:204px;position:relative;touch-action:none}.sf-queue-reorder .track{position:absolute;top:0;left:0;right:0;height:64px;display:flex;gap:12px;align-items:center;background:#333536;border:1px solid transparent;border-radius:9px;padding:7px;transition:transform 220ms cubic-bezier(.22,1,.36,1),box-shadow 160ms,background-color 160ms;user-select:none}.sf-queue-reorder .track[data-dragging=true]{transition:none;background:#434646;border-color:#606664;box-shadow:0 10px 20px #11182066;cursor:grabbing}.sf-queue-reorder .track[data-dragging=true] button{cursor:grabbing;outline:none}.sf-queue-reorder .number{width:12px;text-align:center;color:#858b87;font-size:10px;font-variant-numeric:tabular-nums}.sf-queue-reorder .cover{display:block;width:46px;height:46px;border-radius:4px;overflow:hidden;box-shadow:0 1px 3px #0005;flex:none}.sf-queue-reorder .cover svg{width:100%;height:100%;display:block}.sf-queue-reorder .track-label{flex:1;min-width:0}.sf-queue-reorder .track strong{display:block;font-size:12px;font-weight:450;letter-spacing:-.1px}.sf-queue-reorder small{display:block;color:#a0a6a0;font-size:10px;margin-top:3px}.sf-queue-reorder .duration{font-size:10px;color:#9aa19a;font-variant-numeric:tabular-nums}.sf-queue-reorder .grip{width:44px;height:48px;display:grid;place-items:center;background:none;border:0;padding:0;color:#9ca49c}.sf-queue-reorder .grip svg{width:15px;height:20px;fill:currentColor}.sf-queue-reorder .sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden}.sf-queue-reorder[data-still=true] *{transition:none!important}@media(prefers-reduced-motion:reduce){.sf-queue-reorder *{transition:none!important}}`}</style>
    </section>
  )
}

Where it works well

Reorder a short listening queue with immediate spatial feedback and equivalent keyboard controls.

Reduced motion

Finite transitions become immediate under reduced motion and keyboard input. Host pause disables controls; any owned drag is cancelled.

Things to know

Fixed three-row queue and original vector covers; no playback, virtualized rows, auto-scroll or cross-list transfer.

More examples