{
  "name": "drag-drop-flow",
  "type": "registry:block",
  "description": "Cursor drags a file out of a media library into a drop zone that arms, catches it, and uploads it to completion",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "code-syntax",
    "layout",
    "motion-tokens",
    "path-utils"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/drag-drop-flow/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { CODE_THEMES } from \"@/remotion/lib/code-syntax\";\nimport { getSafeAreaPadding } from \"@/remotion/lib/layout\";\nimport { EASING } from \"@/remotion/lib/motion-tokens\";\nimport { samplePath, waypointsToPath } from \"@/remotion/lib/path-utils\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type DragDropFlowProps = {\n  /** File the cursor picks up and drops. */\n  fileName?: string;\n  /** Size shown once the upload completes. */\n  fileSize?: string;\n  /** Other rows in the source list, for context around the dragged file. */\n  siblings?: string[];\n  /** Heading on the source panel. */\n  sourceLabel?: string;\n  /** Idle prompt inside the drop zone. */\n  label?: string;\n  /** Secondary line under the prompt — accepted formats, limits. */\n  hint?: string;\n  /** Prompt while the file is held over the zone. */\n  activeLabel?: string;\n  /** Label once the upload finishes. */\n  doneLabel?: string;\n  accentColor?: string;\n  backgroundColor?: string;\n  theme?: \"dark\" | \"light\";\n  /** Animation speed multiplier. */\n  speed?: number;\n};\n\nconst DEFAULT_SIBLINGS = [\"b-roll-rooftop.mov\", \"interview-cam-b.mp4\"];\n\n/** Beat plan in seconds — every frame number below derives from these. */\nconst T = {\n  panel: 0,\n  rows: 0.28,\n  cursorIn: 0.45,\n  reach: 1.0,\n  press: 1.02,\n  liftEnd: 1.22,\n  dragStart: 1.12,\n  dragEnd: 2.12,\n  over: 1.62,\n  release: 2.12,\n  settleEnd: 2.5,\n  uploadStart: 2.42,\n  uploadEnd: 3.68,\n  doneEnd: 4.05,\n} as const;\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst FileGlyph: React.FC<{ size: number; color: string }> = ({\n  size,\n  color,\n}) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n    <path\n      d=\"M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5L13.5 3Z\"\n      stroke={color}\n      strokeWidth={1.6}\n      strokeLinejoin=\"round\"\n    />\n    <path d=\"M13.3 3.2V8.7h5.5\" stroke={color} strokeWidth={1.6} />\n    <path\n      d=\"m10.4 12.6 4 2.4-4 2.4v-4.8Z\"\n      fill={color}\n      stroke={color}\n      strokeWidth={1.4}\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst UploadGlyph: React.FC<{ size: number; color: string }> = ({\n  size,\n  color,\n}) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n    <path\n      d=\"M12 15.5V4.2m0 0L7.6 8.6M12 4.2l4.4 4.4\"\n      stroke={color}\n      strokeWidth={1.7}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n    <path\n      d=\"M4.5 15v3.2a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8V15\"\n      stroke={color}\n      strokeWidth={1.7}\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\n\nconst CheckGlyph: React.FC<{\n  size: number;\n  color: string;\n  progress: number;\n}> = ({ size, color, progress }) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n    <path\n      d=\"M4.8 12.6l4.9 4.9 9.5-10.4\"\n      stroke={color}\n      strokeWidth={2.4}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeDasharray={22}\n      strokeDashoffset={22 * (1 - progress)}\n    />\n  </svg>\n);\n\n/** Pointer arrow, drawn rather than typed so it renders on every platform. */\nconst CursorGlyph: React.FC<{ size: number; fill: string; stroke: string }> = ({\n  size,\n  fill,\n  stroke,\n}) => (\n  <svg width={size} height={size * 1.35} viewBox=\"0 0 17 23\" fill=\"none\">\n    <path\n      d=\"M1.4 1.2 15.2 12.1l-6.1.6-3.2 6.3-4.5-17.8Z\"\n      fill={fill}\n      stroke={stroke}\n      strokeWidth={1.4}\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\n/**\n * The whole drag beat rather than a card sliding into a box: the cursor picks\n * a file out of the library, the row it left keeps its place as a ghost, the\n * drop zone arms while the file is held over it, and the release turns into a\n * real upload that completes.\n */\nexport const DragDropFlow: React.FC<DragDropFlowProps> = ({\n  fileName = \"hero-take.mp4\",\n  fileSize = \"48.2 MB\",\n  siblings = DEFAULT_SIBLINGS,\n  sourceLabel = \"Media library\",\n  label = \"Drop your clip\",\n  hint = \"MP4 or MOV, up to 2 GB\",\n  activeLabel = \"Release to upload\",\n  doneLabel = \"Uploaded\",\n  accentColor = \"#E8B86D\",\n  backgroundColor,\n  theme = \"dark\",\n  speed = 1,\n}) => {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const palette = CODE_THEMES[theme];\n  const safe = getSafeAreaPadding({ width, height });\n\n  /** Seconds → frames, compressed by `speed`. */\n  const at = (seconds: number) => (seconds * fps) / speed;\n  const ease = (from: number, to: number, easing = EASING.enter) =>\n    interpolate(frame, [at(from), at(to)], [0, 1], { easing, ...clamp });\n\n  const stage = {\n    x: safe.paddingLeft,\n    y: safe.paddingTop,\n    w: width - safe.paddingLeft - safe.paddingRight,\n    h: height - safe.paddingTop - safe.paddingBottom,\n  };\n  const portrait = height > width;\n  /** One typographic unit, so text and radii follow the smaller edge. */\n  const u = Math.min(stage.w / 1120, stage.h / 620);\n\n  const trayPad = 26 * u;\n  const rowH = 74 * u;\n  const rowGap = 12 * u;\n  /** The dragged file sits mid-list so the ghost it leaves reads clearly. */\n  const draggedIndex = Math.min(1, siblings.length);\n  const rows = [\n    ...siblings.slice(0, draggedIndex),\n    fileName,\n    ...siblings.slice(draggedIndex),\n  ].slice(0, 3);\n\n  // Both panels are sized to what they hold and then centred, rather than\n  // stretched to the safe area — a half-empty list panel reads as a bug.\n  const gap = 0.05 * Math.min(stage.w, stage.h * 1.6);\n  const trayH =\n    trayPad * 2 + 46 * u + rows.length * rowH + (rows.length - 1) * rowGap;\n  const zoneH = portrait\n    ? stage.h - trayH - gap\n    : Math.min(stage.h, Math.max(trayH, 400 * u));\n  const tray = portrait\n    ? { x: 0, y: 0, w: stage.w, h: trayH }\n    : { x: 0, y: (stage.h - trayH) / 2, w: stage.w * 0.34, h: trayH };\n  const zone = portrait\n    ? { x: 0, y: trayH + gap, w: stage.w, h: zoneH }\n    : {\n        x: tray.w + gap,\n        y: (stage.h - zoneH) / 2,\n        w: stage.w - tray.w - gap,\n        h: zoneH,\n      };\n\n  const rowsTop = tray.y + trayPad + 46 * u;\n  const rowY = (index: number) => rowsTop + index * (rowH + rowGap);\n\n  const cardW = Math.min(tray.w - trayPad * 2, 360 * u);\n  const cardH = rowH;\n  const homeCenter = {\n    x: tray.x + trayPad + cardW / 2,\n    y: rowY(draggedIndex) + cardH / 2,\n  };\n  /** Where the file lands — high in the zone, so the prompt below stays clear. */\n  const dropCenter = {\n    x: zone.x + zone.w / 2,\n    y: zone.y + zone.h * 0.34,\n  };\n  /** Where the pointer holds the card — its top-left quadrant, as a mouse does. */\n  const grip = { x: cardW * 0.16, y: cardH * 0.18 };\n\n  // The cursor travels on two arcs: in to the file, then out to the zone.\n  // Keeping them separate lets the grab land on an exact beat instead of on a\n  // guessed fraction of one long path.\n  const approach = waypointsToPath(\n    [\n      { x: tray.x + tray.w * 0.12, y: stage.h + 90 * u },\n      { x: homeCenter.x - cardW * 0.1, y: homeCenter.y + rowH * 1.9 },\n      { x: homeCenter.x + grip.x, y: homeCenter.y + grip.y },\n    ],\n    0.6,\n  );\n  const carry = waypointsToPath(\n    [\n      { x: homeCenter.x + grip.x, y: homeCenter.y + grip.y },\n      {\n        x: (homeCenter.x + dropCenter.x) / 2,\n        y: Math.min(homeCenter.y, dropCenter.y) - stage.h * 0.16,\n      },\n      { x: dropCenter.x + grip.x, y: dropCenter.y + grip.y },\n    ],\n    0.9,\n  );\n\n  const carrying = frame >= at(T.dragStart);\n  const cursorPoint = carrying\n    ? samplePath(carry, ease(T.dragStart, T.dragEnd, EASING.editorial))\n    : samplePath(approach, ease(T.cursorIn, T.reach, EASING.editorial));\n\n  const press = interpolate(\n    frame,\n    [at(T.press), at(T.press + 0.09), at(T.liftEnd)],\n    [0, 1, 0],\n    clamp,\n  );\n  const lift = interpolate(\n    frame,\n    [at(T.press), at(T.liftEnd), at(T.release), at(T.settleEnd)],\n    [0, 1, 1, 0],\n    { easing: EASING.editorial, ...clamp },\n  );\n  const released = frame >= at(T.release);\n  const settle = spring({\n    frame: frame - at(T.release),\n    fps,\n    config: { damping: 15, stiffness: 150, mass: 0.7 },\n  });\n  const armed = interpolate(\n    frame,\n    [at(T.over), at(T.over + 0.22), at(T.release), at(T.release + 0.3)],\n    [0, 1, 1, 0],\n    clamp,\n  );\n  const upload = ease(T.uploadStart, T.uploadEnd, EASING.editorial);\n  const done = ease(T.uploadEnd, T.doneEnd);\n  const panel = spring({\n    frame,\n    fps,\n    config: { damping: 18, stiffness: 120, mass: 0.8 },\n  });\n\n  // The card is parented to the cursor while carried, then springs into the\n  // zone after the release.\n  const cardCenter = released\n    ? {\n        x: interpolate(settle, [0, 1], [cursorPoint.x - grip.x, dropCenter.x]),\n        y: interpolate(settle, [0, 1], [cursorPoint.y - grip.y, dropCenter.y]),\n      }\n    : carrying\n      ? { x: cursorPoint.x - grip.x, y: cursorPoint.y - grip.y }\n      : homeCenter;\n\n  const zoneArmed = armed > 0 || released;\n  const zoneLabel = released\n    ? upload < 1\n      ? \"Uploading\"\n      : doneLabel\n    : armed > 0.5\n      ? activeLabel\n      : label;\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        background: backgroundColor ?? palette.page,\n        fontFamily,\n        position: \"relative\",\n        overflow: \"hidden\",\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: `radial-gradient(ellipse 60% 55% at ${\n            ((stage.x + dropCenter.x) / width) * 100\n          }% ${((stage.y + dropCenter.y) / height) * 100}%, ${accentColor}${\n            zoneArmed ? \"22\" : \"10\"\n          }, transparent 70%)`,\n        }}\n      />\n\n      <div\n        style={{\n          position: \"absolute\",\n          left: stage.x,\n          top: stage.y,\n          width: stage.w,\n          height: stage.h,\n        }}\n      >\n        {/* Source list */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: tray.x,\n            top: tray.y,\n            width: tray.w,\n            height: tray.h,\n            borderRadius: 22 * u,\n            background: palette.window,\n            border: `1px solid ${palette.border}`,\n            boxShadow: `inset 0 1px 0 ${palette.highlight}, 0 ${26 * u}px ${\n              70 * u\n            }px ${palette.shadow}`,\n            opacity: panel,\n            transform: `translateX(${(1 - panel) * -22 * u}px)`,\n          }}\n        >\n          <div\n            style={{\n              position: \"absolute\",\n              left: trayPad,\n              top: trayPad,\n              color: palette.dim,\n              fontSize: 19 * u,\n              fontWeight: 600,\n              letterSpacing: \"0.08em\",\n              textTransform: \"uppercase\",\n            }}\n          >\n            {sourceLabel}\n          </div>\n        </div>\n\n        {rows.map((name, index) => {\n          const isDragged = index === draggedIndex;\n          const rowIn = interpolate(\n            frame,\n            [at(T.rows + index * 0.08), at(T.rows + index * 0.08 + 0.4)],\n            [0, 1],\n            { easing: EASING.enter, ...clamp },\n          );\n\n          if (isDragged) {\n            // The slot the file left — dashed, and only while it is away.\n            return (\n              <div\n                key={name}\n                style={{\n                  position: \"absolute\",\n                  left: tray.x + trayPad,\n                  top: rowY(index),\n                  width: cardW,\n                  height: rowH,\n                  borderRadius: 14 * u,\n                  border: `1px dashed ${palette.faint}`,\n                  // Stays after the drop: the file left the library for the\n                  // upload, so its slot should still read as vacated.\n                  opacity: rowIn * Math.max(lift * 0.9, released ? 0.55 : 0),\n                }}\n              />\n            );\n          }\n\n          return (\n            <div\n              key={name}\n              style={{\n                position: \"absolute\",\n                left: tray.x + trayPad,\n                top: rowY(index),\n                width: cardW,\n                height: rowH,\n                borderRadius: 14 * u,\n                border: `1px solid ${palette.border}`,\n                background: palette.band,\n                opacity: rowIn,\n                transform: `translateY(${(1 - rowIn) * 14 * u}px)`,\n                display: \"flex\",\n                alignItems: \"center\",\n                gap: 14 * u,\n                paddingLeft: 16 * u,\n                color: palette.dim,\n                fontSize: 21 * u,\n                fontWeight: 500,\n              }}\n            >\n              <FileGlyph size={26 * u} color={palette.faint} />\n              <span style={{ whiteSpace: \"nowrap\", overflow: \"hidden\" }}>\n                {name}\n              </span>\n            </div>\n          );\n        })}\n\n        {/* Drop zone */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: zone.x,\n            top: zone.y,\n            width: zone.w,\n            height: zone.h,\n            borderRadius: 26 * u,\n            border: `${2 * u}px dashed ${\n              zoneArmed ? accentColor : palette.border\n            }`,\n            background: zoneArmed\n              ? `linear-gradient(180deg, ${accentColor}${\n                  released ? \"0C\" : \"16\"\n                }, transparent 72%)`\n              : palette.band,\n            boxShadow: `0 0 ${\n              52 * u * Math.max(armed, released ? 0.3 : 0)\n            }px ${accentColor}55`,\n            opacity: panel,\n            transform: `scale(${1 + armed * 0.012})`,\n          }}\n        />\n\n        {/* Zone copy — centred while the zone waits, tucked under the file once\n            it lands, so the two never share the same band */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: zone.x,\n            top: interpolate(\n              released ? settle : 0,\n              [0, 1],\n              [zone.y + zone.h * 0.58, dropCenter.y + cardH * 0.5 + 82 * u],\n            ),\n            width: zone.w,\n            display: \"flex\",\n            flexDirection: \"column\",\n            alignItems: \"center\",\n            gap: 10 * u,\n            textAlign: \"center\",\n            transform: \"translateY(-50%)\",\n          }}\n        >\n          {released ? null : (\n            <div style={{ transform: `translateY(${armed * -6 * u}px)` }}>\n              <UploadGlyph\n                size={44 * u}\n                color={armed > 0.4 ? accentColor : palette.dim}\n              />\n            </div>\n          )}\n          <div\n            style={{\n              color: done > 0 ? accentColor : palette.fg,\n              fontSize: 30 * u,\n              fontWeight: 600,\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 10 * u,\n            }}\n          >\n            {done > 0 ? (\n              <CheckGlyph size={28 * u} color={accentColor} progress={done} />\n            ) : null}\n            {zoneLabel}\n          </div>\n          <div style={{ color: palette.faint, fontSize: 20 * u }}>\n            {released ? `${fileName} · ${fileSize}` : hint}\n          </div>\n        </div>\n\n        {/* The file itself — one element from library row to landed upload */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: cardCenter.x - cardW / 2,\n            top: cardCenter.y - cardH / 2,\n            width: cardW,\n            height: cardH,\n            borderRadius: 14 * u,\n            background: palette.window,\n            border: `1px solid ${\n              lift > 0.2 || released ? `${accentColor}88` : palette.border\n            }`,\n            boxShadow: `0 ${(6 + lift * 22) * u}px ${(16 + lift * 46) * u}px ${\n              palette.shadow\n            }`,\n            transform: `scale(${1 + lift * 0.06 - press * 0.04}) rotate(${\n              lift * -2.2 * (1 - settle)\n            }deg)`,\n            opacity: interpolate(\n              frame,\n              [at(T.rows), at(T.rows + 0.4)],\n              [0, 1],\n              clamp,\n            ),\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 14 * u,\n            paddingLeft: 16 * u,\n            paddingRight: 16 * u,\n            color: palette.fg,\n            fontSize: 21 * u,\n            fontWeight: 500,\n            overflow: \"hidden\",\n          }}\n        >\n          <FileGlyph size={26 * u} color={accentColor} />\n          <span style={{ whiteSpace: \"nowrap\" }}>{fileName}</span>\n          <div\n            style={{\n              position: \"absolute\",\n              left: 0,\n              bottom: 0,\n              height: 4 * u,\n              width: `${upload * 100}%`,\n              background: accentColor,\n              opacity: released ? 1 : 0,\n            }}\n          />\n        </div>\n\n        {/* Transfer readout under the landed file */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: dropCenter.x - cardW / 2,\n            top: dropCenter.y + cardH * 0.5 + 14 * u,\n            width: cardW,\n            display: \"flex\",\n            justifyContent: \"space-between\",\n            color: palette.dim,\n            fontSize: 17 * u,\n            fontVariantNumeric: \"tabular-nums\",\n            opacity: released ? settle : 0,\n          }}\n        >\n          <span />\n          <span>{Math.round(upload * 100)}%</span>\n        </div>\n\n        {/* Click ring, then the cursor on top of it */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: cursorPoint.x - 30 * u,\n            top: cursorPoint.y - 30 * u,\n            width: 60 * u,\n            height: 60 * u,\n            borderRadius: \"50%\",\n            border: `${2 * u}px solid ${accentColor}`,\n            // Only after the release — a clamped ramp would otherwise hold the\n            // ring at full strength for the whole approach.\n            opacity: released\n              ? interpolate(\n                  frame,\n                  [at(T.release), at(T.release + 0.34)],\n                  [0.75, 0],\n                  clamp,\n                )\n              : 0,\n            transform: `scale(${interpolate(\n              frame,\n              [at(T.release), at(T.release + 0.34)],\n              [0.4, 1.5],\n              clamp,\n            )})`,\n          }}\n        />\n        <div\n          style={{\n            position: \"absolute\",\n            left: cursorPoint.x,\n            top: cursorPoint.y,\n            opacity: interpolate(\n              frame,\n              [\n                at(T.cursorIn),\n                at(T.cursorIn + 0.2),\n                at(T.release + 0.25),\n                at(T.release + 0.6),\n              ],\n              [0, 1, 1, 0],\n              clamp,\n            ),\n            transform: `scale(${1 - press * 0.14})`,\n            transformOrigin: \"top left\",\n          }}\n        >\n          <CursorGlyph\n            size={19 * u}\n            fill={theme === \"dark\" ? \"#F6F7FA\" : \"#1A1D24\"}\n            stroke={\n              theme === \"dark\" ? \"rgba(0,0,0,0.5)\" : \"rgba(255,255,255,0.7)\"\n            }\n          />\n        </div>\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "time",
    "tier": "core",
    "tags": [
      "ui"
    ]
  }
}