Skip to content

Ink and Optics

Jace edited this page Jun 6, 2026 · 1 revision

Ink and Optics

The mark you see is ink laid on paper, then composited against the page. This page covers the two halves of that model: the ink group (flow, viscosity, feathering, streakiness, dryout, flowFade, startEndBuildup), the paper it soaks into, and the optics that decide how the result blends with the text underneath (blendMode, opacity, glow).

Everything here is part of Options-Reference and resolves through resolveOptions. Ink fields are deterministic: the same seed (or the same target, when seed is omitted) produces byte-identical texture on server and client. See How-It-Works for the rendering pipeline and Color-and-Palettes for the colorant itself.

All ink fields are tier-dependent. The full texture (feathering, streaks, dryout gaps, end pooling) renders on the SVG tier. The CSS and highlight-api tiers honour color, opacity, and blendMode but flatten the organic texture to a clean band. Degrade is fidelity-only: a lower tier never moves or recolours a mark. See Performance.


The ink group (ink)

ink is a namespaced option group. Pass only the fields you want to change; the rest fall back to the defaults below (they merge field-wise, not all-or-nothing).

Field Type Default Range Effect
flow number 0.45 01 Deposit amount. Raises band width and softens edges.
viscosity number 0.5 01 Inverse of flow. Raises edge sharpness and skip frequency.
feathering number 0.2 01 Capillary lateral spread at the edges.
streakiness number 0.25 01 Lengthwise lighter/darker lanes within a stroke.
dryout number 0.1 01 Probabilistic alpha gaps (skipping). Coupled to viscosity.
startEndBuildup number 0.1 -11 Deposit variation at stroke ends. Positive pools ink, negative suppresses end darkening.
flowFade number 0.5 01 Directional dry-out along each line: saturated at touchdown, drier toward the end.

All fields are normalized 01 except startEndBuildup, which is signed (-11). Non-finite values fall back to the default.

flow vs viscosity

These are the two primary knobs and they pull against each other. flow is how wet the nib is: more ink, wider band, softer edges. viscosity is the opposite tendency: thicker ink sits where it lands, so edges sharpen and the stroke skips more often. A wet marker is high flow / low viscosity; a near-dry one is the reverse.

import { highlight } from "@highlighters/core";

// Wet, generous marker
highlight(".lede", { ink: { flow: 0.85, viscosity: 0.2 } });

// Dry, scratchy marker
highlight(".aside", { ink: { flow: 0.2, viscosity: 0.85, dryout: 0.4 } });

feathering

Lateral capillary spread at the long edges of the band. At 0 the edge stays where the geometry puts it; higher values let ink wick sideways, softening and widening the boundary. Feathering also responds to the paper (paper.absorbency below): a thirstier sheet wicks more for the same feathering value.

streakiness

Lengthwise lanes of lighter and darker ink running along the stroke, the way a real chisel nib lays uneven coverage. At 0 the fill is even.

dryout

Probabilistic gaps where the nib skips the paper and leaves bare patches. Coupled to viscosity: thicker ink skips more, so raising viscosity raises the effective skipping even at a fixed dryout.

startEndBuildup (signed)

Deposit variation at the two ends of each stroke, where a real pen pauses on touchdown and lift-off.

  • Positive pools ink at the ends (darker caps), like a pen pressing in and out.
  • 0 is neutral.
  • Negative suppresses end darkening, for a stroke that fades cleanly off each edge.

flowFade

Directional dry-out along the line: the stroke is wettest at touchdown (the leading edge) and dries toward the trailing end. It follows stroke direction, so a backward drag in Selection-Highlighting fades from the right edge instead.

Ink recipes

Look flow viscosity feathering streakiness dryout flowFade
Default 0.45 0.5 0.2 0.25 0.1 0.5
Wet fountain pen 0.85 0.2 0.35 0.1 0.0 0.3
Dry chalk marker 0.25 0.8 0.1 0.5 0.45 0.7
Clean, flat band 0.5 0.5 0.0 0.0 0.0 0.0
Heavy end-pooling 0.6 0.4 0.2 0.2 0.1 0.4 + startEndBuildup: 0.8
// Dry chalk marker
highlight("h2", {
  ink: { flow: 0.25, viscosity: 0.8, feathering: 0.1, streakiness: 0.5, dryout: 0.45, flowFade: 0.7 },
});

// Clean, flat band (texture off; edges still come from the `edge` group)
highlight("h2", {
  ink: { flow: 0.5, viscosity: 0.5, feathering: 0, streakiness: 0, dryout: 0, flowFade: 0 },
});

For the related edge geometry (waviness, roughness, caps, corner radius) see Tips-and-Edges. Ink controls coverage; edge controls the shape of the band boundary.


Paper (paper)

The surface the ink soaks into. One field today.

Field Type Default Range Effect
absorbency number 0.3 01 Higher wicks more, growing feather and softening edges.

absorbency is a multiplier on the wicking behaviour: a thirsty sheet (high absorbency) makes the same ink.feathering spread further and the edges read softer. Glossy stock (low absorbency) keeps ink tight to the geometry.

// Soft, blotter-paper edges
highlight("blockquote", {
  ink: { feathering: 0.4 },
  paper: { absorbency: 0.8 },
});

// Crisp, coated-stock edges
highlight("code", {
  paper: { absorbency: 0.05 },
});

Optics: how ink composites

Once ink is laid down, three options decide how it interacts with the text and background beneath it: blendMode, opacity, and the additive glow.

blendMode

How the ink composites against whatever is behind it. This is the single most important optics knob, because it decides whether a yellow mark over black text reads as a real highlighter (text stays legible) or paints over it.

BlendMode Optics Behaviour over text
"multiply" (default) Subtractive Darkens. Ink tints the page; dark text shows through. This is how a real highlighter works.
"darken" Per-channel min Keeps the darker of ink and backdrop per channel. Text that is darker than the ink survives.
"color-burn" Aggressive darken Deeper, more saturated darkening than multiply.
"overlay" Contrast Multiply in shadows, screen in highlights. Punchier, can shift hue.
"screen" Additive lighten Lightens the backdrop. For glow-like / dark-mode marks, not classic highlighting.
"normal" Opaque source-over Paints the ink straight on top. With high opacity this hides the text; pair with low opacity.
// Classic marker over light text (default)
highlight("p", { color: "#fff14d", blendMode: "multiply" });

// Dark-mode glow band: lighten instead of darken
highlight("p", { color: "#5ad7ff", blendMode: "screen", opacity: 0.4 });

Overlap darkening

With multiply (and the other subtractive modes), overlapping marks darken where they cross, exactly like passing a highlighter over the same words twice. This is a property of the compositing model, not a separate option. Two marks of the same colour over the same run will read noticeably deeper at the overlap.

Within a single wrapped run the renderer already overlaps the inner edges of adjacent line-bands (see tip.overshoot in Tips-and-Edges), so multi-line marks stay continuous around the wrap without a seam.

If you want stacked marks not to darken, switch to "normal" and lower opacity, or use "darken" (which clamps to the per-channel minimum rather than accumulating).

opacity

Option Type Default Range Effect
opacity number 0.55 01 (clamped) Overall ink alpha.

Top-level alpha for the whole mark, clamped to 01. The default 0.55 keeps text legible under a multiply mark. Raise it for a denser tint; lower it when using "normal" so the ink does not bury the text.

Per-stop alpha is separate: a gradient stop carries its own opacity that multiplies against this top-level value. See Color-and-Palettes.

Colorant

The ink colour itself is set by color / palette / gradient, documented in full in Color-and-Palettes. In brief:

highlight("p", { color: "#f5e6a8" });                          // CSS color string
highlight("p", { color: { palette: "mild", swatch: "pink" } }); // palette swatch
highlight("p", { palette: "fluorescent" });                     // family default swatch

color accepts any CSS color string (hex, rgb(), hsl(), named, currentColor, var(...)) or a { palette, swatch } reference. An empty or whitespace-only string is treated as unset and falls back to the palette default (mild -> yellow = #f5e6a8).

Not a real option: some framework JSDoc snippets show preset (e.g. { preset: "wet" }) or a bare named colour like "pink" as a palette shortcut. Neither exists. There is no preset field anywhere in the API, and a bare "pink" is passed straight through as a CSS color (the literal named colour pink), not a palette lookup. To pick a palette swatch use the object form { palette, swatch }. Build "presets" yourself by spreading an options object, see the recipe at the end.


Glow / fluorescence (glow)

Additive emission painted over the multiply ink, for a fluorescent-marker bloom. Off by default.

Field Type Default Range Effect
enabled boolean false Master enable.
intensity number 0.5 01 Additive emission strength.
spread number 4 px Bloom spread radius.
color ColorValue "" CSS color Emission hue. Empty string resolves to a brightened form of the ink colour at render time.

Leaving glow.color empty ("") lets the renderer derive a brightened version of the mark's ink colour, so the bloom matches the highlighter automatically. Set it explicitly for a contrasting halo.

// Fluorescent marker with an auto-matched bloom
highlight(".key-term", {
  palette: "fluorescent",
  glow: { enabled: true, intensity: 0.7, spread: 6 },
});

// Cyan ink, custom magenta halo
highlight(".key-term", {
  color: "#5ad7ff",
  glow: { enabled: true, intensity: 0.6, color: "#ff6fae" },
});

Glow is part of the SVG-tier texture: on the CSS and highlight-api tiers the bloom is dropped along with the rest of the organic detail. Like all motion-adjacent richness, plan for it to be absent under degrade. See Performance and Accessibility-and-Reflow.


Framework usage

The ink and optics options are plain HighlightOptions fields, so they pass through every binding identically.

React

import { Highlight } from "@highlighters/react";

export function Lede({ children }: { children: React.ReactNode }) {
  return (
    <Highlight
      options={{
        color: { palette: "fluorescent", swatch: "yellow" },
        blendMode: "multiply",
        opacity: 0.6,
        ink: { flow: 0.85, viscosity: 0.2, feathering: 0.35 },
        paper: { absorbency: 0.6 },
        glow: { enabled: true, intensity: 0.7 },
      }}
    >
      {children}
    </Highlight>
  );
}
import { useRef } from "react";
import { useHighlight } from "@highlighters/react";

function KeyTerm() {
  const ref = useRef<HTMLSpanElement>(null);
  useHighlight(ref, {
    color: "#5ad7ff",
    blendMode: "screen",
    ink: { dryout: 0.3, streakiness: 0.4 },
  });
  return <span ref={ref}>capillary action</span>;
}

Vue

<script setup>
import { Highlight } from "@highlighters/vue";

const options = {
  palette: "fluorescent",
  blendMode: "multiply",
  opacity: 0.6,
  ink: { flow: 0.85, viscosity: 0.2 },
  paper: { absorbency: 0.6 },
  glow: { enabled: true, intensity: 0.7 },
};
</script>

<template>
  <Highlight :options="options">market-defining release</Highlight>
</template>

Svelte

<script>
  import { highlight } from "@highlighters/svelte";
</script>

<p use:highlight={{
  color: "#f5e6a8",
  blendMode: "multiply",
  ink: { flow: 0.6, startEndBuildup: 0.8, flowFade: 0.4 },
  paper: { absorbency: 0.8 },
}}>
  Ink pools at the ends of this stroke.
</p>

Vanilla

import { highlight } from "@highlighters/core";

highlight(".aside", {
  blendMode: "darken",
  opacity: 0.5,
  ink: { flow: 0.2, viscosity: 0.85, dryout: 0.4 },
  paper: { absorbency: 0.05 },
});

Speed-aware ink (Beta)

The speed group modulates ink wetness from drag velocity: a faster swipe deposits less ink, a deceleration into a line end pools it there. It engages only under highlightSelection during a primary-button fine-pointer (mouse/pen) drag, is suppressed under prefers-reduced-motion, and is a byte-identical no-op for programmatic, keyboard, touch, and static highlights. It is off by default (speed.enabled: false). Full field table and behaviour live in Selection-Highlighting and Options-Reference.


Building your own "presets"

There is no preset option, but a preset is just an options object you spread. Define them once and reuse:

import { highlight, type HighlightOptions } from "@highlighters/core";

const WET: HighlightOptions = {
  blendMode: "multiply",
  opacity: 0.6,
  ink: { flow: 0.85, viscosity: 0.2, feathering: 0.35, dryout: 0 },
  paper: { absorbency: 0.7 },
};

const DRY: HighlightOptions = {
  blendMode: "multiply",
  opacity: 0.5,
  ink: { flow: 0.2, viscosity: 0.85, streakiness: 0.5, dryout: 0.45 },
  paper: { absorbency: 0.05 },
};

highlight(".lede", { ...WET, color: { palette: "fluorescent", swatch: "yellow" } });
highlight(".aside", { ...DRY, color: { palette: "vintage", swatch: "rust" } });

Because the namespaced groups (ink, paper, glow, ...) merge field-wise via mergeOptions, you can layer a base preset and override a single field:

highlight(".lede", { ...WET, ink: { ...WET.ink, dryout: 0.2 } });

More patterns in Recipes.


See also

Clone this wiki locally