Clause positions
NewA negotiation card that sets their draft against your redlines, clause by clause, on tracks you can drag.
clause-positions/clause-positions.tsxPositions
Their draft vs your redlines
6 clauses · 3 redlinedPlaybook v3
- Liability cap
- Indemnity
- Auto-renewal
- Payment terms
- Termination
- Governing law
- Their draft
- Redline
- Walk-away
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
- Reviewing a contract against a playbook, where each clause has a known range of terms you accept, would discuss or walk away from.
- Showing at a glance how far your redlines sit from their draft, clause by clause, and which ones cross a line.
- Terms that run in an order from best to worst for you: months, days, caps, or named options like “mutual, capped”.
- Letting a reviewer move a position term by term and see the summary and the warning follow, before anything is sent.
- Five to eight clauses on one card, beside a document or in an assistant’s panel.
When not to use
- Editing the clause’s wording itself. Use a redline view of the text, with insertions and deletions in place.
- Free numbers with no fixed set of terms (any amount, any date). Use a number field or a plain slider.
- Terms with no order between them, where “further right” means nothing. Use a select or a set of options.
- A long contract with dozens of clauses. Use a table of clauses with a status column, and open one card per clause.
- Showing a single risk score. Use a meter or a badge: the tracks need a draft and a position to compare.
You can add this component with the shadcn CLI or by hand:
Into components/clause-positions of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/clause-positions.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.2 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 { ClausePositions, type Clause } from './clause-positions';const clauses: Clause[] = [ { name: 'Notice period', unit: 'd', zones: [0.4, 0.75], terms: [ { at: 0.1, label: '15' }, { at: 0.3, label: '30' }, { at: 0.55, label: '60' }, { at: 0.85, label: '90' }, ], draft: 2, redline: 1, },];export function Review() { return <ClausePositions clauses={clauses} onChange={(clause, term) => save(clause, term)} />;}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.
Each clause of a contract is a track in three zones: what you accept, what you would discuss, and a hatched walk-away. Their draft is an open ring; your position is a dot joined to it by a dashed arrow. Drag a dot, or use the arrow keys, and the terms change as it moves; bring it back to the ring and the clause is agreed; push it into the walk-away and it turns red and the card says so.
- Every clause has a list of terms, each at a place on its track from 0 to 1, and two zone bounds. Your position is one of those terms; their draft is another. The label reads “their term → yours”, set in monospace when the terms are numbers with a unit.
- Each track is a size container. The ring and the dot sit on zero-width layers moved by translateX(var(--at) * 100cqw), so a move is a transform and never a layout, and keys or a release glide there in 260 ms.
- The dashed arrow is a single line the width of the track, clipped with clip-path: inset() to the stretch between the ring and the dot, minus the ring on one side and the dot and its chevron on the other. It transitions with the dot, so the two stay together; the chevron flips when the dot passes to the other side of the ring.
- A drag captures the pointer on the track: the dot follows it at once, the label shows the nearest term as it goes, and on release the dot settles on that term. Pressing anywhere on the track jumps there first. Back on their draft the clause is agreed: the ring and the arrow fade, the dot turns plain.
- A position in the walk-away zone turns the dot, the label and the hatching red, adds the count to the summary line and is announced.
- On first view each redline appears on its ring and travels along its arrow to where it stands, in 900 ms, 140 ms after the row above.
- While on screen and untouched, a demo pointer drags a redline term by term into the walk-away and back, then redlines an agreed clause and agrees it again; the dot glides with the pointer on the same 260 ms curve. Any key or press hands it to the reader where it is.
- 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 on a charcoal stage at night, a white one on a pale grey stage by day, where the plain dot turns dark.
| Prop | Type | Default | Description |
|---|---|---|---|
clauses | Clause[] | DEFAULT_CLAUSES | One row each: a name, its terms ({ at, label }, from the most favourable to you to the least, at from 0 to 1), the zones ([end of acceptable, start of walk-away]), the draft’s and the starting redline’s term indexes, and an optional unit that sets the pair in monospace. |
title | string | 'Positions' | The card’s heading. |
subtitle | string | 'Their draft vs your redlines' | The line under the heading. |
playbook | string | 'Playbook v3' | The note on the right of the summary line. |
onChange | (clause: number, term: number) => void | — | Called when a position settles on a term, by a drag, a press or a key. |
autoplay | boolean | true | While on screen and untouched, a demo pointer drags redlines around. Any key or press hands it to the reader. |
reveal | boolean | true | On first view, each redline travels from its ring to where it stands. false starts them in place and keeps the demo off, for a static instance. |
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 | — | Extra class for the root. |
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, useId, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './clause-positions.module.css';/* * Clause positions: a negotiation card that sets their draft of a contract against your redlines, * one clause a row. Each row is a track in three zones (what you accept, what you'd discuss, and * the hatched walk-away), with their draft as an open ring and your position as a dot. * * - Your position is a slider over the clause's terms. Drag the dot (or press anywhere on the/* Clause positions (clause-positions.tsx): a dark card on a dark stage by night, a white one on a pale stage by day. Inside it, a sunken panel with one row per clause: a name, a value, and a track in three zones with their draft (a ring) and your position (a dot) on it. 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). Every track is a size container: the ring and the dot sit on zero-width layers moved by translateX(var(--at) * 100cqw), so a move is a transform and never a layout. The dashed arrow is one line the width of the track, clipped (inset) to the stretch between the ring and the dot. */.root { /* The stage, the card, and the sunken panel inside it. */ --stage: #f1f1f2; --card: #fff; --card-edge: rgb(0 0 0 / 0.07); --card-shadow: rgb(0 0 0 / 0.06); --panel: #fafafa; --panel-edge: #ebebed; /* The header's round icon. */ --icon: #efeff1; --icon-line: #b4b4b9; --icon-knob: #47474c;// 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. * A drag is a straight flight, so a dot dragged along with the pointer stays under it. 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 };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer to `to`, in the stage's own px, in `ms`: on a curve, or straight. */// 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 section named by its heading; the clauses are a list. Each dot is a slider named by its clause, with its term index as the value and “24 mo, their draft 12 mo, past your walk-away” or “30 d, agreed” as the value text.
- Arrow keys move one term, Page Up and Page Down two, Home and End to the ends, and Escape back to their draft. Focus shows a ring round the dot and lights the row.
- Entering the walk-away zone is announced in a polite live region; the visible pair of terms is hidden from screen readers, which hear the slider’s value text instead.
- The rings, arrows, zones and the demo pointer are decorative. Under reduced motion the redlines are simply in place, moves are instant and the demo never plays.