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…
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 each mark through
Path2D built from the SAME path string the SVG is showing — one geometry
implementation, so the raster can never drift from the preview. 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.
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.