Color picker
A grid of swatches. The current value is forwarded as a prop so the picker can render it as selected; resolves with the chosen hex or null.
The Callable
declared once, mounted in the React treeimport { useEffect, useRef } from 'react'import { createCallable } from 'react-call'
interface Props { swatches: readonly string[] current?: string}
export const ColorPicker = createCallable<Props, string | null>( ({ call, swatches, current }) => { const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => { const onPointer = (e: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(e.target as Node)) { call.end(null) } } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') call.end(null) } document.addEventListener('mousedown', onPointer) document.addEventListener('keydown', onKey) return () => { document.removeEventListener('mousedown', onPointer) document.removeEventListener('keydown', onKey) } }, [call])
return ( <div role="dialog" aria-modal="true" aria-label="Pick a color" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" > <div ref={panelRef} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-4 shadow-2xl" > <p className="mb-3 text-sm font-medium text-[var(--color-fg)]"> Pick a color </p> <div className="grid grid-cols-6 gap-2"> {swatches.map((color) => ( <button key={color} type="button" aria-label={color} onClick={() => call.end(color)} style={{ backgroundColor: color }} className={ color === current ? 'h-9 w-9 rounded-md ring-2 ring-[var(--color-fg)] ring-offset-2 ring-offset-[var(--color-bg)]' : 'h-9 w-9 rounded-md transition-transform hover:scale-110' } /> ))} </div> </div> </div> ) },)ColorPicker.displayName = 'ColorPicker'The Root
mounted in your app tree, onceimport { ColorPicker } from './ColorPicker'import { ColorSwatch } from './ColorSwatch'
export default function App() { return ( <> <ColorPicker /> <ColorSwatch /> </> )}The caller
anywhere in your app, imperativeimport { useState } from 'react'import { ColorPicker } from './ColorPicker'
const SWATCHES = [ '#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#e11d74', '#a3a3a3', '#000000', '#ffffff', '#f59e0b',] as const
export const ColorSwatch = () => { const [color, setColor] = useState<string>('#e11d74')
const handleClick = async () => { const next = await ColorPicker.call({ swatches: SWATCHES, current: color }) if (next) setColor(next) }
return ( <div className="flex flex-col items-center gap-3"> <button type="button" onClick={handleClick} className="flex items-center gap-3 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-1.5 text-sm text-[var(--color-fg)] transition-colors hover:border-[var(--color-border-strong)]" > <span aria-hidden="true" style={{ backgroundColor: color }} className="h-5 w-5 rounded border border-[var(--color-border-strong)]" /> <span className="font-mono text-xs">{color}</span> </button> <span className="font-mono text-xs text-[var(--color-fg-subtle)]"> click to change </span> </div> )}Value in, value out
The picker has to highlight the colour you already have — but it doesn’t
own that colour. Your component does. The current value goes in as the
current prop, the chosen one comes back as the Response, and the
Callable keeps no selection state of its own. It’s a pure async function,
(current) => Promise<next | null>, so the whole read-modify-write loop
collapses to a single await.
The | null is the “changed my mind” branch: dismissing (backdrop or
Esc) resolves null, the caller skips the write, and the value is left
exactly as it was. You commit only on a real choice — which is why the
caller guards with if (next) before it stores the result.
When to reach for it
Any “edit one value from a known set” interaction — colour, icon, tag, status, assignee. Show the current value, let the user swap it, and write back only when they actually picked something.