Why This Question Exists
Color Swatch tests controlled input synchronisation — one of the most common sources of bugs in form-heavy applications. The challenge is keeping two input sources (click vs type) in sync without breaking either one's UX. Candidates who use a single state variable and reset on invalid input will have an input that fights the user's cursor position.
The Wrong Approach: Single State
// ❌ WRONG — resets input mid-type, fighting cursor position
const [color, setColor] = useState('#3b82f6');
const onType = (e) => {
const v = e.target.value;
if (isValidHex(v)) {
setColor(v); // only updates on valid — input resets on every invalid keystroke
}
};
When the user types '#3b8' (invalid mid-type), setColor doesn't update, so React re-renders the input with the old valid value. The cursor jumps to the end. The user can't type freely.
The Right Approach: Two-State Separation
// ✅ CORRECT — draft input vs committed color
const [selected, setSelected] = useState('#3b82f6'); // applied color
const [inputVal, setInputVal] = useState('#3b82f6'); // what's in the field
const onType = (e) => {
const v = e.target.value;
setInputVal(v); // always update — never fight the cursor
if (isValidHex(v)) setSelected(v); // only apply when valid
};
const pickSwatch = (hex) => {
setSelected(hex); // both sync on swatch click
setInputVal(hex);
};
This is the same pattern used by React Hook Form, Formik, and every production form library: maintain a "draft" value in the field independently of the "committed" model value.
ℹ Interview TipSay: "I separate the committed color from the draft input value. This is the controlled input pattern used by every major form library — the field owns its own draft, validation runs against it, and only a passing value propagates to the model."
Hex Validation
const isValidHex = (v) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v);
The regex covers both shorthand (#RGB, e.g. #f00) and full (#RRGGBB, e.g. #ff0000). The ^ and $ anchors ensure no extra characters are accepted. The alternation | handles both lengths.
Selected Swatch Ring — CSS Trick
/* Double box-shadow creates gap + ring without extra markup */
.swatch.selected {
box-shadow:
0 0 0 2px #0f172a, /* matches page background — creates gap */
0 0 0 4px #f8fafc; /* visible white ring */
}
The first shadow is the same color as the page background — it creates a visual gap between the swatch and the ring. The second shadow is the actual ring. No extra wrapper element needed. This works on any swatch color because the gap always matches the page background.
Normalising Hex for Comparison
// Shorthand #RGB !== full #RRGGBB even for same color
// '#f00' !== '#ff0000' — use a consistent format for comparison
const normalise = (hex) => {
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
return '#' + hex[1]+hex[1]+hex[2]+hex[2]+hex[3]+hex[3];
}
return hex.toLowerCase();
};
If you store presets in full format but the user types shorthand, the swatch won't highlight. Normalise both to the same format before comparing, or store presets in the format you expect.
⚠ Common Pitfall: Not clamping maxLengthWithout maxLength={7} on the input, a user can type arbitrarily long strings. Your regex will reject them, but the UX is confusing. Always constrain the input to the maximum valid length (#RRGGBB = 7 chars).