Script Zone Dragger

A generic sort-cards-into-zones interaction, tuned for teaching script loading strategies. Each card is a script with a size in KB; each zone is a loading bucket (blocking, defer, async, lazy — or whatever your lesson needs). Drag a card or focus one and press a zone shortcut to move it. Per-zone totals roll up the KB sum so consumers can build a budget walkthrough on top.

Preview

Script loading strategy

Sort each script into the zone that matches how it should load.

Drag a card, or focus one and press 1 Blocking2 Defer3 Async4 Lazy/ cycle zones

Blocking1 script · 110KB

Runs before parse continues.

framework.js110KB
core
Defer2 scripts · 50KB

Runs after HTML parse.

router.js32KB
carousel.js18KB
Async2 scripts · 128KB

Runs as soon as ready.

analytics.js48KB
csv-parser.js80KB
worker-safe
Lazy1 script · 64KB

Loads on interaction or viewport.

chat-widget.js64KB · third-party
Customize
Layout
4
Options

Installation

npx shadcn@latest add https://craftbits.dev/r/script-zone-dragger.json

Usage

import { useState } from "react";
import {
  ScriptZoneDragger,
  type ScriptZoneAssignments,
} from "@craft-bits/core";
 
const [assignments, setAssignments] = useState<ScriptZoneAssignments>({
  framework: "blocking",
  analytics: "async",
  chat: "lazy",
});
 
<ScriptZoneDragger
  scripts={[
    { id: "framework", label: "framework.js", sizeKb: 110 },
    { id: "analytics", label: "analytics.js", sizeKb: 48 },
    { id: "chat", label: "chat-widget.js", sizeKb: 64 },
  ]}
  zones={[
    { id: "blocking", label: "Blocking", keyShortcut: "1" },
    { id: "defer", label: "Defer", keyShortcut: "2" },
    { id: "async", label: "Async", keyShortcut: "3" },
    { id: "lazy", label: "Lazy", keyShortcut: "4" },
  ]}
  assignments={assignments}
  onAssignmentsChange={setAssignments}
/>

Anatomy

  • Header. Optional title rendered with the cb-label style, optional description sub-line, and a Reset button that restores the assignments to defaultAssignments. Each is independently hideable.
  • Hint. A keyboard-shortcut hint sentence: one chip per zone shortcut plus arrow keys for cycling. Hide with hideHint.
  • Zone grid. Auto-fit grid of zones — single column on mobile, two-up on tablet, dense minmax(14rem, 1fr) on desktop. Each zone collects the scripts whose assignment matches its id.
  • Zone header. Zone label, count chip, and optional KB total. The zone tints its label and accent dot to the zone's color.
  • Card. A draggable, focusable button with the script label, size, optional meta line, and optional badge.

Understanding the component

  1. Assignments are a map. Scripts live in a flat array; their zone membership lives in a Record<scriptId, zoneId> map. The component groups scripts at render time so consumers never own the per-zone array shape. Moving a script is a one-field map update.
  2. Native drag, native drop. Every card is draggable. Every zone runs the canonical dragover then preventDefault opt-in — without it, the browser silently rejects the drop and the drop handler never fires. The drag payload travels through dataTransfer with a custom MIME type plus a text/plain fallback.
  3. Depth-counter hover state. A useRef counter ticks up on dragenter and down on dragleave. The zone reads as hovered when the counter is positive, which keeps the dashed border stable when the cursor crosses into a nested card child.
  4. Keyboard fallback. Focus a card and press a zone's keyShortcut to snap into that zone; ArrowRight / ArrowDown cycles forward, ArrowLeft / ArrowUp cycles back. The shortcut chips in the hint sentence match the actual zone shortcuts.
  5. Controlled vs uncontrolled. Pass assignments and onAssignmentsChange for controlled; pass defaultAssignments (and skip both) for uncontrolled. onMove fires after every successful move with the script id and the source / target zones.

Props

PropTypeDefaultDescription
scriptsScriptZoneDraggerScript[]requiredScripts to sort.
zonesScriptZoneDraggerZone[]requiredDrop zones the scripts can land in.
assignmentsScriptZoneAssignmentsControlled script-id-to-zone-id map.
defaultAssignmentsScriptZoneAssignments{}Uncontrolled initial assignments.
defaultZoneIdstringfirst zoneZone used for any unassigned script.
onAssignmentsChange(next) => voidFired with the next full map after every move.
onMove(event) => voidFired with { scriptId, fromZone, toZone } after every move.
titleReactNodeOptional heading above the zones.
descriptionReactNodeOptional sub-headline under the title.
hideZoneTotalsbooleanfalseHide the per-zone KB readout.
hideResetbooleanfalseHide the Reset button.
hideHintbooleanfalseHide the keyboard hint sentence.
headingAs'h2' | 'h3' | 'h4''h3'Tag for the title element.
aria-labelstringAccessible name for the outer container.
classNamestringMerged onto the root via cn().

ScriptZoneDraggerScript

FieldTypeDescription
idstringStable identifier.
labelReactNodeVisible card label.
sizeKbnumberSize in KB — feeds the per-zone total.
metaReactNodeOptional sub-label rendered under the label.
badgeReactNodeOptional badge shown on the right.

ScriptZoneDraggerZone

FieldTypeDescription
idstringStable identifier — matched against the assignments map.
labelReactNodeVisible heading.
descriptionReactNodeOptional helper text under the label.
colorstringOptional CSS colour — drives the label tint, accent dot, and hover border.
keyShortcutstringOptional printable character that snaps the focused card into this zone.
emptyLabelReactNodeOptional empty-state placeholder.

Accessibility

  • The wrapper is a <section> with data-cb-edu="script-zone-dragger". Each zone renders as role="region" with aria-labelledby pointing at its heading.
  • Cards are focusable elements with role="button", descriptive aria-label, and the native draggable attribute. Drags are real dragstart then dragover (with preventDefault) then drop cycles.
  • Keyboard users press the zone's keyShortcut to jump there, or ArrowLeft / ArrowRight (also Up / Down) to cycle. The shortcut chips in the hint sentence stay in sync with the actual zone shortcuts.
  • Hovered zones expose data-state="hover" so styling stays unified between pointer and drag-over paths. The drag-source card exposes data-cb-dragging="true" for a translucent placeholder effect.
  • Hover state survives nested card children because dragenter / dragleave are counted with a useRef depth counter, not a boolean — no flicker when the cursor crosses into the size or badge.
  • Reduced-motion preferences short-circuit every enter / exit / layout transition.

Credits

  • Extracted from: terminal-dreams (src/components/frontend-design/perf-javascript/ui/ScriptZoneDragger.tsx). The original was wired to a project-level js-perf-simulator engine, a hard-coded critical/deferred/worker triplet, and a Live TTI headline that summed parse and execute costs of the critical zone. This rewrite drops the engine coupling, the TTI headline, the bounce and insight banners, and the worker-safe rejection rule — consumers pass their own scripts and zones, the zone-shortcut keys live on each zone descriptor instead of a fixed 1 / 2 / 3 map, and the per-zone size sum is the only built-in summary.