Contract dropzone
NewThe empty state of a contract-review assistant: drop a contract, paste a link or pick a source, and it reads every clause.
contract-dropzone/contract-dropzone.tsxDrop a contract to start
Vellum reads every clause, checks it against your playbook and drafts the redlines.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- The first screen of a document-review tool, before anything is in review: one card says what the assistant does and takes the first file.
- Intake where a file, a pasted link and a connected source are all fine ways in, and the person picks whichever is at hand.
- A review that needs one decision up front, such as whose side to take, before the reading starts.
- A short, visible read (seconds, not minutes), where counting the clauses in place is more honest than a spinner.
When not to use
- Once there are reviews to show. Use a list or table of them, such as the contracts table, and keep a small upload button in its header.
- Bulk uploads of many files at once. Use an upload queue with a row and a progress bar per file.
- Any file type, or files with no review behind them. Use a plain file input or a generic dropzone.
- Long jobs that run in the background. Hand off to a status page or a notification rather than holding the card on Reading.
- A form with several required fields. This takes one contract and one choice; a real form fits more.
You can add this component with the shadcn CLI or by hand:
Into components/contract-dropzone of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/contract-dropzone.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ContractDropzone } from './contract-dropzone';export function ReviewsEmptyState() { return ( <ContractDropzone product="Vellum" playbook="Playbook v3" autoplay={false} onSubmit={({ file, link, side }) => startReview({ file, link, side })} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Nothing is in review yet. An isometric drawing of a contract, one clause lifted off it in mint, says what the assistant does; the composer under it takes the first contract. Drag a PDF or DOCX over the card, paste a link, or pull a sample from Drive, E-sign or Inbox, pick whose side to take, and send it: the card reads it clause by clause and counts one more review.
- The drawing is one inline SVG in the inset’s own px (480 × 223), so every point is placed where it lands on a full-width card and the whole of it scales below that. The lines of text are laid out on the top sheet through a bilinear map of its four corners, so they follow its slant.
- The idle motion is three transforms: the mint strip floats 3 px over its clause, its dotted guides stretch with it (each by its own length, from its foot), and a scan line sweeps the sheet every 5.6 s inside a clip of the sheet’s outline. It pauses off screen or in a hidden tab, and stays still under reduced motion.
- The whole card is the drop target. Drag events fire on every child the file crosses, so a counter of entries minus exits says whether the file is over it; only drags that carry files count. Over it, the inset lights mint, the strip lifts 9 px on a springy transition and the copy says to let go.
- A dropped or picked PDF or DOCX becomes an attachment chip in the field; anything else is turned down with the reason, for a moment, and screen readers hear it. The + button opens the same file picker, so nothing depends on dragging.
- Send wakes up once there is something to read: a file, or a link in the field. Sending counts 18 clauses in 130 ms steps while the scan line quickens and the button spins, then says what it found for 2.6 s; the header counts one more contract in review.
- Customer or Vendor is a radio group (arrow keys move it) with one thumb that slides and resizes to the checked option.
- While on screen and untouched, a demo pointer carries a file in, picks Vendor, sends it, then types a link and sends that too. It never takes the page’s focus, and any input hands control to the reader.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme, or the colorScheme prop: a near-black card with grey linework at night, a white one with soft greys by day, the mint clause on both.
| Prop | Type | Default | Description |
|---|---|---|---|
product | string | 'Vellum' | The assistant’s name, in the copy under the drawing. |
playbook | string | 'Playbook v3' | The playbook the review checks against, shown on the header’s pill and in the copy. |
defaultSide | 'customer' | 'vendor' | 'customer' | Whose side the review takes at first. |
onSubmit | (contract: { file: File | null; name: string; link: string | null; side: 'customer' | 'vendor' }) => void | — | Called when a contract is sent: the dropped or picked file (null for a link or a source sample), its name, the link, and the side. |
defaultState | { file?: { name: string; size: string }; link?: string; over?: boolean; refused?: string; phase?: 'reading' | 'done'; clause?: number; reviews?: number } | — | Opens it in a given state, held there: a contract attached, a link pasted, a file over the card or turned down, mid-read on a clause, or done. It turns the demo off. For documentation and tests. |
autoplay | boolean | true | While on screen and untouched, a demo pointer drags a contract in and sends it, then pastes a link. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Added to the stage, for sizing and placement. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useRef, useState, type DragEvent as ReactDragEvent, type FormEvent, type KeyboardEvent as ReactKeyboardEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { Illustration } from './illustration';import { useAutoplay } from './use-autoplay';import styles from './contract-dropzone.module.css';/* * Contract dropzone: the empty state of a contract-review assistant. Nothing is in review yet; * a drawing of a contract with one clause lifted off it says what the assistant does, and the * composer under it takes the first contract: a dropped or picked PDF or DOCX, a pasted link, or * a file from one of the sources under it. *// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId, type CSSProperties } from 'react';import styles from './contract-dropzone.module.css';/* * The empty state's drawing, in isometric linework: a stack of three sheets, the top one curling * at its corners, with one clause lifted off it as a mint strip; a pen and a desk stamp beside it. * * It is drawn in the inset's own px (480 × 223), so every point here is where it lands on a card * at its full width; the SVG scales as a whole below that. The lines of text on the top sheet are * laid out on the sheet itself (a bilinear map of its four corners), so they follow its slant. * * What moves (all by transforms, in the stylesheet): the strip floats over its clause and its * dotted guides stretch with it; a scan line sweeps the sheet now and then, quicker while a * contract is being read. All of it is decorative (aria-hidden on the inset). */type P = [number, number];/** The top sheet's corners, as drawn (before its corners curl). */const TL: P = [236.4, 39.9];const TR: P = [332.5, 69.4];const BL: P = [117.5, 135];/* Contract dropzone (contract-dropzone.tsx): a card on a quiet stage, 502px wide at most. Its sizes are px of the card at that width; under it, the card takes the stage's width and the drawing scales as a whole (it keeps its proportions). Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Motion: the strip's float, the guides' stretch and the scan line are transforms and opacity on a few SVG groups, paused when the stage is off screen (the component sets data-paused) and still under reduced motion. */.root { /* The stage, the card, and the inset the drawing sits in. */ --stage: #efefed; --card: #fff; --card-ring: rgb(0 0 0 / 0.07); --card-shadow: rgb(0 0 0 / 0.05); --inset: #f7f7f6; --inset-edge: #ebebe9; /* Words: strong, quiet, and the placeholder. */ --ink: #161616; --muted: #76767a; --faint: #a3a3a5;// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the file the demo * drags, which plays its own animation on the same clock). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(- The card is a labelled section; the header’s count, the copy and every state change are plain text, and attachments, refusals, reading and results are announced in a polite live region.
- Dragging is never required: the + button opens a file picker that accepts the same PDF and DOCX files, and the field takes a link from the keyboard; Enter sends.
- Send is a real submit button marked aria-disabled until there is something to read, so it stays focusable and says why nothing happens; while reading its label says so.
- Customer or Vendor is a radiogroup of two radios with a roving tab stop and arrow keys; the attachment’s remove button names the file it removes.
- The drawing is decorative and aria-hidden. Its float, guides and scan line stop under reduced motion, as do the demo and the springy transitions.
- Every control shows a 2 px focus ring; the field shows it on the whole composer.