Hooks

useEyeDropper

The screen eyedropper as a hook: open() resolves to a typed outcome — picked, canceled, unsupported or failed — instead of throwing, with hydration-safe support detection, a one-pick-at-a-time guard, and an AbortSignal that unmount fires for you.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The one method this hook calls on the browser's `EyeDropper`, declared locally on purpose.
 *
 * TypeScript's DOM lib does not declare `EyeDropper` at all — the API shipped in Chromium and
 * nowhere else — so there is nothing to import. `declare global` is the other option and it is
 * the wrong one for a file that gets copied into someone else's repo: the day their TS version
 * (or another dependency) ships its own declaration, two globals collide and the build breaks in
 * a file they never edited. A local structural type plus one cast where `window` is read is inert
 * by comparison, and it doubles as the seam a scripted stand-in can satisfy in tests.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-eye-dropper.json

Prompt

Build a React + TypeScript "useEyeDropper" hook (React only — no npm
dependencies; the browser EyeDropper API).

Contract
- `useEyeDropper()` takes no arguments and returns
  `{ isSupported, isPicking, value, lastOutcome, open, cancel, reset }`.
- `open(options?): Promise<EyeDropperOutcome>`, where `options` is
  `{ signal?: AbortSignal }`. It never throws and never rejects: every ending
  is a value.
- `EyeDropperOutcome` is a four-arm discriminated union:
  - `{ status: "picked", hex, sRGBHex, rgb }` — `hex` is normalised
    `#rrggbb` in lower case, `sRGBHex` is the engine's raw string, `rgb` is
    `{ r, g, b }` in 0–255, or `null` if the raw string was not hex at all.
  - `{ status: "canceled", by: "user" | "signal" }`.
  - `{ status: "unsupported" }` — nothing was opened.
  - `{ status: "failed", reason, name, message }`, `reason` in
    `"no-user-activation" | "already-open" | "unknown"`. `name` / `message`
    are the engine's raw text: show them in a details block, but branch UI
    copy on `reason`, because every engine words the message differently.
- `isSupported` — `window.EyeDropper` exists here. `isPicking` — an overlay is
  on screen right now. `value` — the last picked hex, or `null`. `lastOutcome`
  — how the last call ended.
- `value` only moves on a successful pick. A cancel or a failure leaves it
  exactly as it was: pressing Escape must not blank the colour the visitor
  already chose.
- `lastOutcome` is "how the last CALL ended", so a call refused by the
  in-flight guard settles immediately and can read `already-open` while
  `isPicking` is still true. Read `isPicking` for the overlay, `lastOutcome`
  for the last answer.
- `open` / `cancel` / `reset` are referentially stable for the life of the
  hook (`useCallback` with no changing deps — support is read inside `open`
  at call time, not closed over), so they are safe in dependency arrays.
- Declare the API's types locally — an `EyeDropperConstructor` whose instance
  has `open(options?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }>`
  — and read it once as
  `(window as Window & { EyeDropper?: EyeDropperConstructor }).EyeDropper`.
  TypeScript's DOM lib does not declare `EyeDropper`, and a `declare global`
  in a file that gets copied into someone else's repo collides the day their
  lib version adds its own. The local type is also the seam a scripted
  stand-in satisfies in tests.

Behavior
- Capability detection never happens during render. Read `window.EyeDropper`
  through `useSyncExternalStore(noopSubscribe, detect, () => false)`, so the
  server and the hydrating first paint both say `false` — the honest answer
  where there is no window — and React swaps in the truth right after
  hydration. Reading `window` during render is a hydration mismatch.
- Support is genuinely narrow: Chromium desktop only, no Firefox, no Safari,
  not Chromium on Android. Treat `isSupported` as part of the contract, not a
  footnote, and never make the eyedropper the only route to a colour.
- ONE PICK AT A TIME, guaranteed synchronously. Keep the in-flight
  `AbortController` in a ref that is read AND written in the same synchronous
  step at the top of `open()`, before the first `await`. A double click, a
  click plus its keyboard twin, or a loop calling `open()` twice therefore
  opens exactly one overlay: the second call settles at once as
  `failed` / `already-open` and never reaches the API.
- Order inside `open()`: in-flight guard, then capability, then
  "is the caller's signal already aborted" (short-circuit to
  `canceled` / `by: "signal"`, opening nothing), then install the controller,
  then call the API.
- No `await` before the API call. `open()` requires live transient user
  activation, so everything up to `new EyeDropper().open(...)` runs
  synchronously in the click's own task. A consumer that awaits a slow fetch
  first, or calls from an effect or a timer, spends the window and comes back
  as `failed` / `no-user-activation` — a call-site bug, not a retryable one.
- Always create an internal `AbortController` even when the caller passed a
  signal, and forward the caller's `abort` to it with a listener removed in
  `finally`. That internal signal is what makes the next rule possible.
- `AbortError` covers BOTH exits — the visitor pressing Escape and an abort
  the page requested — and the engine tells you nothing more. Read
  `controller.signal.aborted` at rejection time: aborted means
  `by: "signal"`, not aborted means `by: "user"`. The two deserve opposite
  copy ("nothing was picked, try again" versus "we closed it for you").
- Map the other rejections by `name`: `NotAllowedError` →
  `no-user-activation`, `InvalidStateError` → `already-open`, anything else →
  `unknown`. Read `name` / `message` STRUCTURALLY (a typed property read on an
  object), not via `instanceof DOMException` or `instanceof Error`:
  `DOMException` only became an `Error` subclass in a later WebIDL revision
  and engines adopted it at different times, and a stand-in may reject with a
  plain object.
- Hex normalisation maths, applied to `sRGBHex`: accept `#` plus 3, 4, 6 or 8
  hex digits; expand the short forms by doubling each digit (`#abc` →
  `#aabbcc`); drop the alpha pair from the 4- and 8-digit forms (a screen
  sample is already composited — there is no transparency left to report);
  lower-case the result; parse each of the three pairs with `parseInt(pair,
  16)` into 0–255. Anything that is not a hex string comes back untouched
  with `rgb: null` rather than crashing a colour field on a value nobody has
  ever seen.
- Cleanup, all of it: unmount aborts the in-flight controller (otherwise the
  panel is gone and the magnifier is still on screen), clears the ref, and
  flips a `mountedRef` that suppresses every setState afterwards — while the
  outcome is still RETURNED to whoever called `open()`. Set that ref to `true`
  INSIDE the mount effect body, not only cleared in cleanup, or StrictMode's
  mount → cleanup → mount leaves the live instance permanently muted. In
  `finally`, remove the forwarded abort listener and release the ref only if
  it still holds THIS call's controller, so a straggler cannot free a newer
  pick's slot.
- The hook has no deadline of its own. A pick that stays open is the
  visitor's business; a page that wants a budget passes its own signal and
  aborts it, which is what the timed card in the preview does.
- Honest about accuracy: the value is the COMPOSITED SCREEN pixel. Subpixel
  antialiasing, the display's colour profile, OS scaling and video overlays
  all mean the sample can differ slightly from the CSS colour that produced
  it. This is a sampling accelerator, not a colour-measurement instrument.

Rendering & styling
- The hook renders nothing; the consumer owns all UI.
- Keyboard map: the trigger is a real `button`, so Enter and Space activate it
  and both carry transient activation. Once the overlay is up it is browser
  UI — Escape cancels it, the engine handles that, and the page cannot see
  those keys. Do NOT add a global Escape listener: it can never reach the
  overlay and will fire on the keypress the browser already consumed. Focus
  never leaves the page, so it is still on the trigger when the pick settles,
  and nothing needs restoring.
- ARIA contract: give the trigger a text name (`aria-label` when it is an
  icon-only pipette), set `aria-busy` while `isPicking`, and put the outcome
  in an ALWAYS-MOUNTED `role="status"` region so the change is announced
  rather than the region appearing. Colour is never the message: render the
  hex as text next to the swatch and mark the swatch `aria-hidden`. Where the
  trigger has to go inert, use `aria-disabled` plus a guard in the handler,
  never the native attribute — a natively disabled control drops focus to
  `body`. On an unsupported engine, either do not render the trigger at all or
  leave it operable and let the `unsupported` outcome explain itself; a
  silently dead button is the one option that is wrong.
- Semantic tokens only for chrome: `bg-card`, `bg-background`, `bg-muted`,
  `text-muted-foreground`, `border`, `focus-visible:ring-ring`, and
  `bg-destructive/10` + `text-destructive` for the `failed` arm. The single
  exception is the swatch itself — `style={{ backgroundColor: hex }}` is
  runtime data sampled off the screen, and no token can stand in for it.
- If a spinner marks the picking state, give it `motion-reduce:animate-none`;
  nothing about the feature depends on the animation.
- Rendering the hex ON the swatch instead of beside it? Choose the label
  colour by WCAG relative luminance rather than by eye: linearise each
  channel `c = ch / 255`, `lin = c <= 0.03928 ? c / 12.92 : ((c + 0.055) /
  1.055) ** 2.4`, then `L = 0.2126 r + 0.7152 g + 0.0722 b`, and compare
  `(L + 0.05) / 0.05` against `1.05 / (L + 0.05)` to pick black or white.

Customization levers
- Trigger shape — an icon-only pipette in a dense toolbar, or a full row with
  swatch, hex, copy button and a reset. The hook is the same either way.
- Support gating — hide the trigger when `isSupported` is false, or keep it
  and let the `unsupported` outcome carry the explanation. Either is fine;
  always keep a typeable hex field as the path that works everywhere.
- Deadline — wrap `open({ signal })` with your own `AbortController` plus a
  timeout when a pick must not stay open forever. Clear the timer when the
  call settles and on unmount.
- Output format — hex is the contract. Convert in the consumer for
  `rgb()` / `hsl()` / `oklch()`, or widen the normaliser to return those
  alongside `rgb`.
- Recent picks — the hook keeps only the latest; hold an array of the last N
  in the consumer and key the rows by a counter, not by the hex, since the
  same colour can be sampled twice.
- Sampling several colours in a row — every `open()` needs its own activation,
  so drive it from one click per pick; a loop cannot keep the eyedropper open
  across samples.
- Testing — swap the `window.EyeDropper` read for an injected constructor and
  the whole state machine (pick, cancel, refusal) can be driven with no
  browser at all.

Concepts

  • Cancel as an outcome — Escape is the most common way this API ends, and the raw promise reports it as a rejection. Folding it into { status: "canceled", by: "user" } means the consumer writes one switch over four arms instead of a try/catch whose happy path is "the visitor changed their mind"; nothing about a cancel is exceptional.
  • by: "user" versus by: "signal" — the engine sends the identical AbortError for a keyboard cancel and for a page-requested abort. The hook always opens with a controller it owns, so reading signal.aborted at rejection time separates "you pressed Escape" from "we closed it for you" — two sentences that must never be swapped.
  • Single-flight ref guard — the in-flight controller is read and written in the same synchronous step before any await, so a double click or a loop produces one overlay and one immediate already-open refusal. A state flag cannot do this: state is not visible to the second call in the same tick.
  • Transient activationopen() only works while the click's activation window is live, which is why nothing is awaited in front of the API call. A call from an effect, a timer, or after a slow await comes back as no-user-activation, and retrying it on a timer only reproduces the failure.
  • Hydration-safe capability detectionwindow.EyeDropper is probed through useSyncExternalStore with a false server snapshot instead of during render, so SSR markup and the first client paint agree, and the real answer lands right after hydration. Support is Chromium desktop only today, so this flag is load-bearing rather than defensive.
  • Unmount owns the overlay — the magnifier outlives React otherwise: the panel closes, the picker stays on screen, and its resolution lands on a dead component. Cleanup aborts the controller and mutes later state writes, while still returning the outcome to whoever awaited open().

On This Page