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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-eye-dropper.jsonPrompt
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 oneswitchover four arms instead of atry/catchwhose happy path is "the visitor changed their mind"; nothing about a cancel is exceptional. by: "user"versusby: "signal"— the engine sends the identicalAbortErrorfor a keyboard cancel and for a page-requested abort. The hook always opens with a controller it owns, so readingsignal.abortedat 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 immediatealready-openrefusal. A state flag cannot do this: state is not visible to the second call in the same tick. - Transient activation —
open()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 slowawaitcomes back asno-user-activation, and retrying it on a timer only reproduces the failure. - Hydration-safe capability detection —
window.EyeDropperis probed throughuseSyncExternalStorewith afalseserver 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().
useWebSocket
One WebSocket as a hook — a four-state connection machine, jittered backoff reconnects gated by a shouldReconnect predicate, an ordered outbound queue that covers the outage, JSON with a raw escape hatch, and a heartbeat that catches sockets that are open but dead.
useSound
A pooled Audio hook for short UI sounds — preloaded voices so fast triggers overlap instead of cutting each other off, a page-wide mute and master volume, every refusal named, and every element released on unmount.