Finger Draw Annotate
Mark up a photo with a finger — pen, arrow and highlighter in a thumb-reachable dock, per-mark undo, a caret and arrow keys as the equal non-touch path, and a flattened PNG export.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- use-controllable-state, utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/finger-draw-annotate.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FingerDrawAnnotate" component: mark up a
photo with a finger. React + lucide-react (ArrowUpRight, Check, Download, Eraser,
Highlighter, ImageOff, Pen, Undo2) and one shared useControllableState hook — no
canvas library, no gesture library, no animation library.
It is a phone component, not a shrunken desktop markup editor. The finger is the
only cursor, so there is no hover to hide tools behind; the tools are permanently
visible 44px targets living in the thumb arc and padded out of the home
indicator, the default ink is sized for a fingertip rather than a mouse, and the
component has to take scrolling and pinch-zoom away from the browser for exactly
as long as a mark is being drawn.
Contract
- "use client". forwardRef<HTMLDivElement, FingerDrawAnnotateProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">; rest props and the ref
land on the card root, className merges through cn().
- Backdrop: src? (absent = a blank board, which still draws and still exports),
alt, imageCrossOrigin?: "anonymous" | "use-credentials", aspectRatio = 3/4.
The photo is object-cover, centred, inside the frame the aspect ratio defines.
- Marks: strokes / defaultStrokes / onStrokesChange — controlled and uncontrolled
both supported. FingerDrawStroke = { id, tool, points: {x, y}[], width }, where
x/y are 0..1 fractions of the FRAME, never pixels: that is what lets the same
array survive a resize, a rotation and a 4x raster. An arrow keeps exactly two
points, tail then head; freehand keeps every sample that moved more than ~1.6px.
- Tools: tool / defaultTool / onToolChange over "pen" | "arrow" | "highlight",
plus tools?: FingerDrawTool[] to offer a subset (an empty list falls back to
all three). Ink widths penWidth = 4, arrowWidth = 5, highlightWidth = 18, all
clamped 1..60 through one helper that rejects NaN.
- variant: "dock" | "overlay" | "rail" = "dock" — three layouts, not three colour
schemes. dock puts a segmented tool row with text labels under the photo (the
default, best when the card has room below); overlay floats icon-only tools
over the bottom of the picture so it stays full-bleed (best for a full-screen
markup sheet, at the cost of covering the bottom strip of the drawing area);
rail pins the tools to the trailing edge inside the right thumb's arc.
- Export: onExport?(dataUrl) — given, a save button appears; pressing it composes
the photo and every committed mark into one PNG and hands back a data URL.
exportScale = 2, clamped 1..4. Nothing is exported for an in-flight mark.
- disabled = false blocks pointer, key and button paths. label for the surface's
accessible name, labels?: Partial<FingerDrawLabels> for every string (i18n).
- Export the FingerDrawTool / FingerDrawVariant unions and the FingerDrawStroke,
FingerDrawPoint and FingerDrawLabels interfaces; mirror tool and variant onto
the root as data-tool / data-variant.
Behavior — the browser must not fight the stroke
- Pointer Events only: one path for touch, mouse and pen, never parallel touch +
mouse handlers. Mouse counts the primary button only.
- The drawing surface is a transparent hit layer sitting above the picture and
below the floating tools, carrying touch-action: none. That single declaration
is the whole gesture arbitration story — the browser stops trying to scroll or
pinch while a finger is down — so NOTHING in this component ever calls
preventDefault on a passive listener.
- setPointerCapture on that surface at pointerdown, released at pointerup. A
stroke that runs off the edge of the photo keeps receiving samples; the samples
are clamped to 0..1 so they stop at the frame instead of inking the margins.
- A second pointer while a mark is in flight CANCELS the mark rather than
extending it. The user is pinching the photo, and half a stroke dragged toward
a second contact point is worse than no stroke. pointercancel — the browser
taking the gesture — settles the same way.
- Read getCoalescedEvents() when it exists: a 120Hz screen samples far faster
than it fires pointermove, and the held-back samples are the difference between
a curve and a chain of chords. Freehand is smoothed with the midpoint-quadratic
pass (each sample becomes a control point, the curve passes through the
midpoints), and a stroke that never moved commits as a dot.
- The arrow tool is press-for-tail, drag-for-head, with a live preview and an
open V head whose length is 30% of the shaft clamped to 12..34px. Released
under 16px it is a tap, not an arrow: nothing is kept and the status line says
so, because a zero-length arrow is a dot nobody can explain.
- Equal non-gesture path, mandatory: the surface is focusable, and a 44px caret
appears on focus-visible. Arrow keys move it by 4% of the frame (Shift: 1%),
Space places a point — the first press anchors the mark, the last point stays
rubber-banded to the caret — Enter commits, Escape drops the mark, Backspace /
Delete and Ctrl/Cmd+Z undo. Two presses of Space are a complete arrow. Escape
is only swallowed while a mark is in flight, so an Escape with nothing to drop
still closes the sheet this is sitting in.
- Undo is per mark and pops the last committed one; an unfinished keyboard mark
is the most recent thing drawn, so undo drops that first. Clear empties the
array. Neither button is ever natively disabled — the browser blurs a node the
instant it is disabled, so Clear would drop focus onto <body> from the very
button that was just pressed. aria-disabled plus a guard inside the handler.
- Export draws in this order: the frame's own background, the photo with the same
object-cover maths the browser is using on screen, then — after one
ctx.scale(exportScale) — each mark through Path2D built from the SAME path
string the SVG is showing, at the on-screen line widths: one geometry
implementation, so the raster is a uniform scale of the preview and never
drifts from it (building paths at export size would keep the arrowhead's px
clamp at screen size and shrink every head against its shaft). toDataURL is
wrapped in try/catch: a cross-origin backdrop taints the canvas and throws, and
the status line has to say that rather than hand back a blank PNG.
- Cleanup: the ResizeObserver is disconnected, the save-confirmation timer is
cleared on unmount and before it is re-armed, and pointer capture is released
on the element that took it. There are no global listeners, no rAF and no
matchMedia subscription to leak.
- No wall clock anywhere: mark ids come from useId plus a counter ref, so two
renders of the same page are identical. The counter skips any id the current
marks already carry — marks persisted from an earlier mount share the same
useId prefix and counter values, and a repeat would be a duplicate React key.
Rendering & styling
- Semantic tokens only, monochrome: card root rounded-2xl border bg-card, frame
rounded-lg bg-muted, floating chrome bg-background/85 + backdrop-blur, status
text-[11px] text-muted-foreground with tabular-nums. The selected tool and the
save button INVERT (bg-foreground text-background) rather than taking a colour.
- Ink is currentColor, so it themes itself. Every pen/arrow mark is painted
twice: a wider pass in text-background at 55% opacity, then the ink — black ink
on a night photo is otherwise invisible, and a halo keeps the whole component
on two tokens instead of reaching for a marker red. The highlighter is the same
ink at 35% and takes no halo, because a wash is meant to be read through.
- Committed marks are React-rendered <path> elements; the in-flight mark is two
extra paths with NO d prop, written imperatively from the pointer handlers. A
finger at 120Hz must not re-render the page it is drawing on.
- Safe area: whichever chrome is bottom-most pads with
var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) — the tool
block under the frame in dock and rail, the floating bar in overlay — and the
rail clears the trailing inset with the same expression, so a 44px target never
sits under the home indicator in either orientation. The app-level variable is
read first so a device frame or a preview can simulate a notch. Order the parts
status-then-controls, not the reverse: the controls have to be the last thing
in the box or the padding protects a caption instead of a button.
- Accessibility: the surface is role="application" (deliberate — the arrow keys
have to reach the widget instead of being eaten by browse-mode navigation) with
aria-roledescription, aria-label and aria-describedby pointing at an sr-only
key map. Tools are aria-pressed buttons inside a labelled group rather than a
radiogroup, so each one is Tab-reachable with no roving tabindex. The status
line is a polite live region whose text is DERIVED from the resolved array —
when a controlled parent refuses a mark the string does not change, so nothing
is announced, because announcing a refusal as success would be a lie.
- prefers-reduced-motion: the only motion is the tool's colour transition and it
is dropped through motion-reduce:transition-none. Drawing, undo, export and the
caret are unaffected — nothing about the feature depends on animation.
Customization levers
- variant is the placement decision: dock when the card has room underneath,
overlay for a full-screen markup sheet (accepting that the bar covers the
bottom strip of the photo), rail for one-handed use on a tall screen.
- tools is a list: ship ["pen"] alone and it is a fingertip marker; drop "arrow"
where a shaft would be read as a measurement.
- Ink widths are the fingertip calibration. 4/5/18 suit a 390px frame; scale them
up together for a tablet, and keep highlightWidth at roughly four times the pen
or the wash stops reading as a highlighter.
- exportScale trades file size against sharpness: 2 is a retina-crisp share, 1 is
a chat thumbnail, 4 is a print-ish artefact. The marks scale with it.
- aspectRatio decides the crop of the frame, and the export inherits it — set it
to the aspect of the screenshots you actually receive.
- The halo pass is one extra <path>; delete it if your backdrop is guaranteed
light, and the whole component collapses to a single ink colour.
- Skin by data-attribute: data-tool, data-variant, and data-slot on frame,
surface, caret, tools, tool, undo, clear, save and status.
- labels carries every string, including the sr-only key map. Long translations
truncate on one line: the tool row keeps its 44px height rather than reflowing.Concepts
- The finger is the cursor — with no hover there is nowhere to hide a tool, so every tool is a permanently visible 44px target inside the thumb arc, and the default ink is calibrated to a fingertip's contact patch rather than a mouse's single pixel.
- Gesture arbitration by
touch-action— the drawing surface declarestouch-action: none, which hands scrolling and pinch-zoom to the component for the length of a stroke. Because the browser was told up front, nothing here ever callspreventDefaulton a passive listener. - Second contact cancels — a stroke belongs to exactly one pointer. A second finger means the photo is being pinched, so the in-flight mark is abandoned rather than dragged toward the new contact point, and a browser-owned takeover arrives as
pointercanceland settles the same way. - Normalised marks — points are stored as fractions of the frame, so the same array replays identically at 320px, at 430px and on a 4x export canvas; nothing has to be re-recorded when the layout moves.
- Flatten on export — the photo and every committed mark are composited into one raster through the same path geometry the screen is showing. That is the deliverable a phone actually shares, and it is also the point of no return: after flattening, a mark is no longer a separable object.
- Equal keyboard path — a 44px caret, arrow keys, Space and Enter place the identical marks with no touch screen involved, and the polite status line is derived from the resolved array so a refused mark is never announced as a success.
Wheel Picker
An iOS-style drum picker: scroll-snap detents under the thumb, optional wrapping, unlandable rows, and the same turns from the keyboard and two chevrons.
Mobile Search Bar
A phone search field that turns the screen into search mode — Cancel, a scope bar and recent searches — measured against the software keyboard and padded off the notch.