Navigate

Search topics across all sections

GitHub
Transforms & Color

Colors & Color Spaces

Color in 3D is more nuanced than picking a hex code. Three.js works internally in Linear color space for physically correct lighting, then converts to sRGB for your monitor. Understanding this pipeline is the difference between colors that look "right" and colors that look washed out or unexpectedly dark.

You pick the perfect orange (#ff6600) in Figma. You paste it into your Three.js material. But the rendered color looks different — slightly darker, more saturated. You tweak the hex code back and forth but can never match the design. The issue isn't the color — it's the color space.

terminal
Material color doesn't match design mockup. Hex #ff6600 renders differently than in CSS.

Real-world

Think of color spaces as paint mixing systems.

RGB is like mixing red, green, and blue spotlights. Where they overlap, you get new colors. All three together make white. It's how screens work, but it's not how humans think about color.

HSL is more intuitive — like a paint store. Pick a hue from the color wheel (red, blue, green...), adjust saturation (how vivid vs gray), and set lightness (how bright vs dark). Much easier to think in.

The twist? Your monitor displays sRGB (gamma-corrected), but physics and lighting math work in Linear space. It's like the difference between raw and processed photos — both represent the same scene, but the math only works correctly on the raw version.

Your Color

Hex, RGB, HSL, or named

Linear Space

Lighting math happens here

Tone Mapping

Compress HDR range

sRGB Output

What your monitor shows

Hands-On: Working with Color

Three.js accepts colors in many formats. Let's explore each one and understand when the color space pipeline matters.

Step 1: Color input formats

App.tsxTSX
// All of these work in R3F:
<meshStandardMaterial color="coral" />       // Named
<meshStandardMaterial color="#ff7f50" />      // Hex
<meshStandardMaterial color="rgb(255,127,80)" /> // RGB
<meshStandardMaterial color={0xff7f50} />     // Hex integer

// THREE.Color object for more control
const myColor = new THREE.Color("coral");
myColor.r // 1.0 (red channel, 0-1)
myColor.g // 0.498
myColor.b // 0.314

R3F accepts any value that THREE.Color can parse: CSS color names, hex strings, RGB strings, hex integers, or a THREE.Color instance. Internally, all colors are stored as three floats (r, g, b) in the 0 to 1 range.

Step 2: HSL for intuitive color picking

App.tsxTSX
// HSL is easier for humans
const color = new THREE.Color();

// setHSL uses 0-1 range for all values
color.setHSL(
  0.6,   // hue (0=red, 0.33=green, 0.66=blue)
  0.8,   // saturation (0=gray, 1=vivid)
  0.5    // lightness (0=black, 0.5=pure, 1=white)
);

// Or parse a CSS HSL string
const color2 = new THREE.Color("hsl(216, 80%, 50%)");

// Get HSL back from any color
const hsl = {};
color.getHSL(hsl);
console.log(hsl.h, hsl.s, hsl.l);

HSL is great for creating color variations programmatically. Want 10 different hues at the same brightness? Loop the hue value from 0 to 1 while keeping saturation and lightness constant.

Step 3: Understanding color spaces

App.tsxTSX
// Three.js pipeline:
// 1. Input color (sRGB)
// 2. Convert to Linear for lighting math
// 3. Apply tone mapping
// 4. Convert back to sRGB for display

// For exact color matching (UI, branding):
<meshBasicMaterial
  color="#ff6600"
  toneMapped={false}  // skip tone mapping
/>

// For physically correct rendering:
<meshStandardMaterial
  color="#ff6600"      // tone mapping applied
  // Colors may shift slightly — that's correct!
/>

Tone mapping compresses HDR values into displayable range. It makes scenes look more natural but shifts exact colors. Disable it on materials that need pixel-perfect color matching.

What you just learned

Three.js accepts colors as named strings, hex, RGB, HSL, or THREE.Color objects.

Internally, lighting calculations happen in Linear color space, then output converts to sRGB for display.

HSL (Hue, Saturation, Lightness) is more intuitive for generating color palettes and variations programmatically.

Use toneMapped={false} on materials that need exact color matching (UI, branding) to bypass tone mapping.

Question

If you mix two colors by averaging their RGB values (e.g., red + blue = purple), does it matter whether you do the math in sRGB or Linear space? What happens to the perceived brightness of the result?

Think about it...

You have a material with color '#808080' (50% gray). Why does it appear darker than expected when using MeshStandardMaterial compared to MeshBasicMaterial?

Hint: Think about what happens after the color is set but before it reaches your screen...

Try These Challenges

Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.

Try This!

Beginner

Pick a bright red — compare sRGB vs Linear

Try This!

Beginner

Set roughness to 0 — see color in reflections

Try This!

Beginner

Try named colors in the picker

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Mixing up sRGB and Linear color spaces
Colors look washed out or too dark compared to your design mockup
Don't do this
App.tsxTSX
// Colors look different than Figma/CSS!
// Three.js uses Linear internally
<meshStandardMaterial color="#ff6600" />
// This orange looks different than
// the same hex in CSS because of
// color space conversion
Three.js computes lighting in Linear space (physically correct) then converts to sRGB for display. This means hex colors from CSS/Figma may appear slightly different. Use toneMapped={false} on materials that need exact color matching, like UI elements.
2
Creating new Color objects every render
Garbage collection spikes from 60 new objects per second
Don't do this
App.tsxTSX
// WRONG: new Color every frame!
function MyMesh() {
  return (
    <mesh>
      <meshStandardMaterial
        color={new THREE.Color(r, g, b)}
      />
    </mesh>
  );
}
Creating new THREE.Color objects every render generates garbage for the GC to clean up. Either reuse a memoized Color, mutate the existing material color, or let R3F handle conversion by passing a string.
3
Assuming HSL values match CSS percentages
hsl(200, 80%, 50%) in CSS doesn't directly map to Three.js setHSL
Don't do this
App.tsxTSX
// WRONG: CSS-style percentages
const color = new THREE.Color();
color.setHSL(200, 80, 50);
// These values are way out of range!
THREE.Color.setHSL() uses normalized 0-1 values, not CSS conventions (0-360 for hue, 0-100% for saturation/lightness). Divide hue by 360 and saturation/lightness by 100, or just pass a CSS string and let Three.js parse it.

Best Practices

Use CSS Color Names for Prototyping

"coral", "royalblue", "gold" are faster to type than hex codes and easier to read. Switch to hex values for production color matching.

Use HSL for Palettes

Generate color variations by looping hue while keeping saturation and lightness constant. Much easier than manually picking harmonious RGB values.

Disable Tone Mapping for UI

For UI elements, brand colors, or anything that needs exact color matching, set toneMapped=false on the material. Let PBR materials keep tone mapping on.

Reuse Color Objects

If you animate colors, create a single THREE.Color with useMemo and mutate it in useFrame. Don't create new Color instances every frame — it creates garbage for the GC.