# Image Hairline: Pure Alpha, Never Tinted

**NEVER** · **ID:** `design-image-hairline-outline` · **Category:** design
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-image-hairline-outline

> NEVER: Tint an image hairline. Separate an image from its surface with a 1px `outline` at low alpha in PURE black (`oklch(0 0 0 / 0.1)`) on light and PURE white (`oklch(1 0 0 / 0.1)`) on dark — never a near-black or near-white from the palette (`slate-900`, `#0a0a0a`, `#f5f5f7`). This is the ONE neutral you must not tint, and the scoped exception to `design-impeccable-tinted-neutrals`: that rule governs surfaces you control, while a hairline sits on arbitrary photographic content, where any hue picks up the surface behind it and reads as dirt on the image edge. Use `outline` with `outline-offset: -1px`, not `border` — an outline is painted outside the layout algorithm, so the image keeps its intended size and a grid stays aligned.

Separate an image from its surface with an inset 1px outline of pure black or pure white at low alpha — the one neutral you must not tint

Read this against design-impeccable-tinted-neutrals, which says the opposite — never #000, never #fff, tint every neutral toward the brand hue. Both are right, and the conflict is kept rather than flattened, because they are scoped to different surfaces. A tinted neutral works when it sits on a surface you control: the hue is a quiet, deliberate relationship between two things you shipped. An image hairline sits on arbitrary photographic content, and on the boundary between that content and whatever surface the image happens to land on today. A hue that is invisible on your neutral-100 card goes muddy-green against a warm cream backdrop and grey-violet against a warm photograph — and because it is a 1px line at the exact edge of a photo, the brain does not read "a subtly tinted separator", it reads dirt on the image. Pure black and pure white at low alpha have no hue to shift, so they read as a shadow of the edge itself under any backdrop. This is the one neutral you must NOT tint. Second half of the rule, and cheap to get wrong: use `outline` with `outline-offset: -1px`, not `border`. A border adds 2px to the box, which knocks a grid of images out of alignment against anything unbordered next to it; an outline is painted outside the layout algorithm entirely, and the negative offset pulls it inside so the image keeps its intended size.

## Rule snippet

```tsx
img { outline: 1px solid oklch(0 0 0 / 0.1); outline-offset: -1px; }
@media (prefers-color-scheme: dark) { img { outline-color: oklch(1 0 0 / 0.1); } }
```

## Bad — do not do this

`design-image-hairline-outline-bad`

```tsx
import { useState } from 'react';
import type { CSSProperties } from 'react';

type Surface = 'cream' | 'white' | 'near-black';

// The card the photos sit on. Cream is the interesting one: it is exactly the kind of
// warm, tinted surface a brand palette produces, and exactly where a slate hairline dies.
const SURFACES: Record<Surface, string> = {
  cream: 'oklch(0.96 0.03 85)',
  white: 'oklch(1 0 0)',
  'near-black': 'oklch(0.18 0.01 265)',
};

const PHOTOS = [
  { label: 'Sunset over the harbour', className: 'bg-gradient-to-br from-amber-200 via-orange-300 to-rose-400' },
  { label: 'Desert road at noon', className: 'bg-gradient-to-br from-yellow-100 via-amber-200 to-stone-300' },
  { label: 'Fog on the ridge', className: 'bg-gradient-to-br from-slate-200 via-slate-300 to-slate-400' },
];

export function ImageHairlineOutlineBad() {
  const [surface, setSurface] = useState<Surface>('cream');

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-1.5">
        <span className="me-1 text-xs text-muted-foreground">Card surface:</span>
        {(Object.keys(SURFACES) as Surface[]).map((s) => (
          <button
            key={s}
            type="button"
            onClick={() => setSurface(s)}
            aria-pressed={surface === s}
            className={`rounded-md border px-2.5 py-1 text-xs font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
              surface === s
                ? 'border-foreground bg-foreground text-background'
                : 'border-border bg-muted text-foreground'
            }`}
          >
            {s}
          </button>
        ))}
      </div>

      <div
        className="rounded-lg bg-[var(--surface)] p-5"
        style={{ '--surface': SURFACES[surface] } as CSSProperties}
      >
        <div className="grid grid-cols-3 gap-3">
          {PHOTOS.map((p) => (
            <div key={p.label} className="space-y-1.5">
              {/* A near-black from the project palette, as a border. Two mistakes in one line. */}
              <div
                role="img"
                aria-label={p.label}
                className={`aspect-square rounded-md border border-slate-800/10 ${p.className}`}
              />
              <p className="truncate text-[10px] text-slate-500">{p.label}</p>
            </div>
          ))}
        </div>
      </div>

      <p className="text-xs text-destructive">
        Cycle the surface. <code>border-slate-800/10</code> is not neutral — it carries slate&apos;s cool
        hue, so against the warm cream card (and against the warm photos) it turns muddy green and reads
        as dirt on the image edge rather than as a separator; on the near-black card the same token
        simply vanishes, because it was only ever a light-mode value. And because it is a{' '}
        <code>border</code>, it is part of the box: it eats a pixel off each edge of the image, so these
        tiles are not the size the grid thinks they are.
      </p>
    </div>
  );
}
```

## Good — do this

`design-image-hairline-outline-good`

```tsx
import { useState } from 'react';
import type { CSSProperties } from 'react';

type Surface = 'cream' | 'white' | 'near-black';

const SURFACES: Record<Surface, { bg: string; dark: boolean }> = {
  cream: { bg: 'oklch(0.96 0.03 85)', dark: false },
  white: { bg: 'oklch(1 0 0)', dark: false },
  'near-black': { bg: 'oklch(0.18 0.01 265)', dark: true },
};

// The only two values allowed. Pure black and pure white have no hue to shift, so they
// cannot pick up the backdrop and read as grime. This is the one neutral you must NOT tint
// (cf. design-impeccable-tinted-neutrals, which is right about every surface you control).
const HAIRLINE_LIGHT = 'oklch(0 0 0 / 0.1)';
const HAIRLINE_DARK = 'oklch(1 0 0 / 0.1)';

const PHOTOS = [
  { label: 'Sunset over the harbour', className: 'bg-gradient-to-br from-amber-200 via-orange-300 to-rose-400' },
  { label: 'Desert road at noon', className: 'bg-gradient-to-br from-yellow-100 via-amber-200 to-stone-300' },
  { label: 'Fog on the ridge', className: 'bg-gradient-to-br from-slate-200 via-slate-300 to-slate-400' },
];

export function ImageHairlineOutlineGood() {
  const [surface, setSurface] = useState<Surface>('cream');
  const { bg, dark } = SURFACES[surface];

  return (
    <div className="space-y-3">
      <style>{`
        /* outline, not border: painted outside the layout algorithm, and the negative
           offset pulls it inside so the image keeps its intended size. */
        .iho-tile {
          outline: 1px solid var(--hairline);
          outline-offset: -1px;
        }
      `}</style>

      <div className="flex flex-wrap items-center gap-1.5">
        <span className="me-1 text-xs text-muted-foreground">Card surface:</span>
        {(Object.keys(SURFACES) as Surface[]).map((s) => (
          <button
            key={s}
            type="button"
            onClick={() => setSurface(s)}
            aria-pressed={surface === s}
            className={`rounded-md border px-2.5 py-1 text-xs font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
              surface === s
                ? 'border-foreground bg-foreground text-background'
                : 'border-border bg-muted text-foreground'
            }`}
          >
            {s}
          </button>
        ))}
      </div>

      <div
        className="rounded-lg bg-[var(--surface)] p-5"
        style={
          {
            '--surface': bg,
            '--hairline': dark ? HAIRLINE_DARK : HAIRLINE_LIGHT,
          } as CSSProperties
        }
      >
        <div className="grid grid-cols-3 gap-3">
          {PHOTOS.map((p) => (
            <div key={p.label} className="space-y-1.5">
              <div
                role="img"
                aria-label={p.label}
                className={`iho-tile aspect-square rounded-md ${p.className}`}
              />
              <p className={`truncate text-[10px] ${dark ? 'text-slate-400' : 'text-slate-500'}`}>
                {p.label}
              </p>
            </div>
          ))}
        </div>
      </div>

      <p className="text-xs text-success">
        Cycle the surface: the hairline holds. Pure black at 10% alpha on light surfaces, pure white at
        10% on dark ones — no hue to pick up the backdrop, so it reads as the shadow of the edge rather
        than as dirt on it. In a real component the whole rule is one class list:{' '}
        <code>outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10</code>. Use{' '}
        <code>outline</code> and not <code>border</code>: an outline does not participate in layout, and{' '}
        <code>-outline-offset-1</code> insets it, so the image stays exactly the size the grid expects.
      </p>
    </div>
  );
}
```

## References

- [jakubkrehel/skills — better-ui: surfaces](https://github.com/jakubkrehel/skills/blob/main/skills/better-ui/surfaces.md)
- [MDN: outline-offset](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/outline-offset)
