Steal a Palette From a Photograph
Sample colors from a reference image, then build a token set that survives hover states and dark mode.
The client sent a beach photograph and said make the site feel like this. Not helpful until you translate pixels into tokens. Sampling five hex values from a JPEG is easy. Turning them into a system with readable text, button hovers, focus rings, chart neutrals, and a dark mode that still feels like the same beach—not a different product—is the work. Photography is continuous tone. Design systems are discrete roles. The bridge is intentional sampling, OKLCH conversion, token expansion, and contrast verification at each step.
A strong reference image encodes temperature, dominant hue, accent moments, and lightness range. It does not encode accessible text pairs, hover feedback, or semantic danger red. Sunlit sand might suggest a surface color that fails contrast for fourteen-pixel body copy. Ocean teal might be gorgeous at three percent of pixel area and unbearable at button scale. Treat the photo as evidence, not a stylesheet. You are building roles that echo the scene, not copying every cluster the camera recorded.
Sampling with roles in mind
The difference between a mood board and a token system is the difference between pointing at a scene and naming what each region must do when a user submits a form at midnight on a phone with brightness turned down. Photographs seduce because they are already harmonious. Cameras compress dynamic range. Designers compress further when they crop for Instagram. Your job is to reverse the compression into roles that survive interaction states, not to paste the crop into a CSS background-image and call the brand done.
Open the reference in our image color picker. Do not click random highlights. Sample regions that map to future token roles. The darkest stable area—wet rock, shadow foliage—candidates for text. Avoid crushed blacks with RGB clipped near zero. The largest mid-tone field—sand, sky haze, wall—candidates for surface. Prefer areas without texture noise. A second mid-tone like foam or mist suggests surface-raised, slightly different L in the same hue family. The most saturated small area—umbrella, boat, flower—candidates for accent, used sparingly and desaturated for UI. The neutral average of the scene sets cool versus warm temperature for your ramp.
| Region to find | Token role | Sampling notes |
|---|---|---|
| Darkest stable area | --text candidate |
Avoid crushed blacks |
| Largest mid-tone field | --surface candidate |
Prefer low texture noise |
| Second mid-tone | --surface-raised |
Same hue family, different L |
| Saturated small area | --accent candidate |
Desaturate before UI scale |
| Scene gray balance | Temperature anchor | Sets cool vs warm neutrals |
Ignore specular white from sun glint or wave crest clip and crushed black from deep shade. They do not tokenize. They clip in sRGB and break when dark mode inverts assumptions. For each role, sample five to nine clicks in a tight region and average mentally, or record hex values and take the median lightness. Single-pixel clicks chase JPEG artifacts. Zoom until you see whether you are on sensor noise or real color. If the image is split between sky and sea, decide which half owns the product temperature. Mixing cool sky neutrals with warm sand accents without a plan produces muddy chrome.
Marketing may ship heavily graded RAW files. Ask for an ungraded export or the approved social crop actually used in campaigns. Sampling from a filtered Instagram version bakes orange-teal split grading into your tokens. When campaigns ship three photographs, sample each independently, then merge seeds rather than averaging hex across unrelated scenes. If accent hue drifts more than fifteen degrees from the locked system when a new photo arrives, treat the image as marketing-only outlier, not a token update trigger.
OKLCH as the drafting layer
Single-pixel sampling is the most common way photo-derived palettes fail in production. The umbrella rib catches a specular highlight and becomes --accent. The sand grain next to a shadow becomes --text-muted. Both look plausible in a five-swatch Figma frame and both fail when applied to forty-eight-pixel button labels. Median sampling across a region is slower and more defensible. Record the samples in a scratch file with region notes so you can explain later why text came from rock shadow rather than from the sky strip that looked prettier in the thumbnail.
Hex and RGB are storage formats. Manipulation belongs in OKLCH: lightness steps perceptually, chroma adjusts saturation without hue drift, hue rotates accents in controlled degrees. Example beach translation from samples:
:root {
--text: oklch(0.28 0.03 250);
--surface: oklch(0.96 0.02 85);
--surface-raised: oklch(0.93 0.025 85);
--accent: oklch(0.58 0.12 195);
--accent-hover: oklch(0.52 0.12 195);
--accent-active: oklch(0.46 0.11 195);
--accent-subtle: oklch(0.94 0.04 195);
--border: oklch(0.88 0.02 85);
}
Build hover by lowering L, not by sampling a second random pixel from deeper water. If sampled accent chroma exceeds roughly 0.14 on large button fields, reduce C by 0.02 to 0.04 before contrast testing. Photos allow saturation UI cannot. Most photos yield three useful seeds: neutral temperature, surface lightness, accent hue. Expand systematically. Hold hue and chroma nearly constant on neutrals; step lightness for a ramp from surface through border to muted text.
:root {
--neutral-1: oklch(0.98 0.015 85);
--neutral-2: oklch(0.94 0.018 85);
--neutral-3: oklch(0.88 0.02 85);
--neutral-4: oklch(0.55 0.02 85);
--neutral-5: oklch(0.28 0.03 85);
}
Photography rarely supplies danger, warning, and success. Derive semantics from functional requirements, not from the photo’s random red kayak. Tune warning lightness upward so dark text passes on yellow fills. Keep semantic hues outside the accent hue angle plus or minus twenty degrees when possible to avoid collision. Do not sample haze for muted text without measurement. --text-muted often needs lower L than the photo suggests.
Case study: From beach JPEG to production tokens
A wellness startup selling coastal retreats had no fixed brand book—only a hero photograph of shallow water, pale sand, dark volcanic rock in the foreground, and a single coral parasol occupying less than four percent of frame area. The founder wanted the product to feel like arrival, not like a generic SaaS blue. Design had forty-eight hours before a investor demo. The temptation was to eyedrop five colors, paste them into a slide, and call it a system.
The first pass did exactly that. Text came from rock shadow at #1a2332. Surface came from sand at #f7f3eb. Accent came from the parasol at #e85d4c. Borders were a manual lighten of sand. In the demo UI, body copy on sand failed WCAG AA at fourteen pixels. The parasol accent on white buttons vibrated at full width. Muted captions sampled from haze above the horizon measured 2.9:1 on sand. Dark mode was filter: invert(1) on a screenshot, which made the ocean accent look like hot pink on charcoal. The investor meeting focused on readability, not serenity.
The second pass treated the JPEG as compass only. Designers reopened the image in the picker at four hundred percent zoom, marked regions on a printed still, and agreed which half of the frame owned temperature before anyone typed hex. Rock shadow converted to oklch(0.28 0.03 250) for text—same hue family as water shadows, not pure neutral gray. Sand became oklch(0.96 0.02 85) for surface with raised at 0.93 L. Parasol coral desaturated from roughly 0.18 chroma in sample to 0.12 at oklch(0.58 0.12 195) for accent, with hover at 0.52 L and active at 0.46 L computed, not re-sampled. Semantic colors were assigned independently: success at hue 145, warning at 85 with high L, danger at 25. None were taken from the photo.
Contrast passes drove further adjustment. --text-muted landed at oklch(0.45 0.02 250), darker than haze suggested. Placeholder at 0.62 L met non-essential text guidance from APCA and WCAG at sixteen pixels. Primary buttons used oklch(0.99 0.01 195) text on accent fill. Focus rings added 0.04 chroma before introducing a second hue. Chart series used analogous teals and warm sand hues at controlled chroma, never the parasol saturation.
Dark mode re-derived from shadow regions, not inverted sand. Surfaces moved to L 0.14 to 0.22 with chroma slightly lower than light mode to reduce glow. Text moved to L 0.92 to 0.96. Accent lowered chroma 0.02 and raised L slightly so hover steps remained visible on dark fills. Borders used lightness contrast at oklch(0.32 0.02 250), not white hex at ten percent opacity alone, because hairlines disappear on OLED. Marketing heroes with text on scrim passed contrast in both themes. The emotional link to the photo came from hue angle continuity—still 195-degree water accent, 85-degree warm neutral family—while lightness architecture flipped.
Between the failed demo and the shipped build, the team ran three internal reviews: a contrast pass at fourteen and sixteen pixel sizes, a dark mode walkthrough on OLED and LCD hardware, and a marketing alignment check that compared hero photography grading to token hue angles without reopening accent chroma. Engineering handoff included CSS custom properties, Figma variables, and a one-page table mapping semantic names to OKLCH and hex fallbacks. Forbidden values were documented: do not use sun-glare #fffef2 as surface. Source image filename, license note, sample region descriptions, and hover math lived in the design system README. Sampling colors does not grant rights to display the photograph; license was confirmed separately before CMS upload. The demo shipped with investor commentary on calm rather than saturation—a useful signal that the second pass had traded spectacle for readability without losing the coastal story. Post-launch, when a forest photograph appeared in a campaign, seeds were diffed against locked OKLCH. Hue drift exceeded fifteen degrees on moss green. Marketing used the image. Tokens did not change.
Dark mode, licensing, and the long game
Place tokens side-by-side with the image in Figma or a simple HTML proof. Squint test: does chrome feel like scene temperature? If accent pulled from a four-percent pixel area vibrates at button scale, desaturate or darken until the button feels related to water, not umbrella. Check simultaneous contrast: sand surface beside ocean accent should not produce afterimage when you look away. If it does, widen lightness separation.
[data-theme="dark"] {
--surface: oklch(0.18 0.02 250);
--surface-raised: oklch(0.22 0.022 250);
--text: oklch(0.94 0.01 250);
--text-muted: oklch(0.72 0.02 250);
--accent: oklch(0.62 0.1 195);
--accent-hover: oklch(0.68 0.1 195);
--border: oklch(0.32 0.02 250);
}
Harmony check against the photograph is a step teams skip when deadlines compress. Place approved tokens beside the reference in Figma or a static HTML proof. Squint until the UI chrome and the photo feel like the same temperature. If they fight, the fault is usually neutral hue angle, not accent. Shift the neutral family ten degrees toward the dominant scene hue before you touch accent again. Accent is loud; neutrals are the temperature contract.
Handoff comments in code reduce drift. Engineering ships faster when every token carries a role comment tied to the sample region, not only a hex fallback. A one-line note that --accent came from shallow water with chroma reduced 0.03 from the parasol sample prevents the next developer from “fixing” the button toward hotter coral because the marketing JPEG looked more saturated on a retina display.
Batch consistency across campaign seasons matters for brands that rotate photography but must not rotate tokens monthly. Lock seeds after the first approved translation. Diff new assets against locked OKLCH. Allow photography to vary within the band. Do not create accent-beach, accent-forest, and accent-city unless information architecture truly demands page-level accents engineers will maintain. Most orgs will not maintain them. Generic tokens with disciplined marketing crops age better.
Wide-gamut displays expose another photo-to-token gap. Sun glint sampled as surface white may clip in display-p3 marketing monitors while looking fine in sRGB Figma proofs. Keep surfaces and text inside sRGB-safe ramps unless you explicitly ship p3 accents with fallbacks. Photography on a wide-gamut camera is not an instruction to push UI chroma to the display edge.
Document the pipeline for future designers: source filename, license, sample region descriptions, OKLCH seeds, hover math, contrast tables per text style, dark mode remap rationale. Future you adjusts tokens without re-clicking random pixels. Legal separates color sampling rights from display rights. Sampling does not grant CMS upload permission.
Failure modes worth naming in prose: overfitting accent chroma makes the product feel like a sports drink; underfitting makes generic gray with one blue button; skipping dark mode forces a theme toggle that looks like a different brand; using photo white or black as text or surface fails contrast and clips in wide-gamut displays. A photograph is a mood compass. OKLCH is the drafting tool. Tokens are the contract with engineering. Sample regions with roles in mind, convert before you scale, expand neutrals and semantics deliberately, derive dark mode from shadow logic not inversion, and verify contrast at real sizes. The beach stays in the product. The JPEG stays out of the CSS. Mood is borrowed from the photograph. Obligations are owed to the user who must read, click, and trust the interface in conditions the photographer never controlled: glare, small screens, and themes you did not mock up in the first critique.