react-call v2

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.

click to change

The Callable

declared once, mounted in the React tree
ColorPicker.tsx
import { 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, once
App.tsx
import { ColorPicker } from './ColorPicker'
import { ColorSwatch } from './ColorSwatch'
export default function App() {
return (
<>
<ColorPicker />
<ColorSwatch />
</>
)
}

The caller

anywhere in your app, imperative
ColorSwatch.tsx
import { 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.

Related examples