Mobile

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.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
use-controllable-state, utils
"use client"

import * as React from "react"
import { ArrowUpRight, Check, Download, Eraser, Highlighter, ImageOff, Pen, Undo2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"

/** The three marks a finger can leave. Mirrored onto the root as `data-tool`. */
export type FingerDrawTool = "pen" | "arrow" | "highlight"

/** Where the tools sit. Three layouts for three thumbs, not three colour schemes. */
export type FingerDrawVariant = "dock" | "overlay" | "rail"

export interface FingerDrawPoint {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/finger-draw-annotate.json

Prompt

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 declares touch-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 calls preventDefault on 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 pointercancel and 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.

On This Page