Skip to content

A marker stroke drawn in reading order, carrying on across line breaks.

Text & contentno dependencies

01Preview

Effective 1 Oct 2026

Changes to our terms

We now bill usage at the end of each calendar month instead of every 30 days, so invoices line up with your accounting period.

Preview deployments are kept for 90 days after their branch merges, and you can export audit logs at any time.

3 changes since 12 Jun

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup.

03Usage

import { Highlight, HighlightGroup } from "@/components/ui/highlight";

<p>
  Usage is now billed <Highlight tone="warning">at the end of each calendar month</Highlight>.
</p>

// Several marks, drawn one after another in reading order, toggled on demand.
<HighlightGroup active={showChanges}>
  <p>Previews are <Highlight tone="success">kept for 90 days</Highlight>.</p>
  <p>You can <Highlight variant="underline">export audit logs</Highlight> any time.</p>
</HighlightGroup>

04Source

"use client";
import { createContext, use, useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";

const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

const tones = {
  neutral: "[--hl:color-mix(in_oklab,var(--fg)_15%,transparent)]",
  warning: "[--hl:color-mix(in_oklab,var(--warning)_26%,transparent)]",
  success: "[--hl:color-mix(in_oklab,var(--success)_22%,transparent)]",
  danger: "[--hl:color-mix(in_oklab,var(--danger)_22%,transparent)]",
  info: "[--hl:color-mix(in_oklab,var(--info)_22%,transparent)]",
} as const;

/** Pen speed in px per second, and the shortest and longest a stroke may take, in seconds. */
const PACE = { speed: 520, min: 0.4, max: 1.2 };

/** Seconds a stroke takes: from its real length across every line it covers, like a pen at a steady pace. */
function strokeTime(el: HTMLElement) {
  const fixed = Number(el.dataset.duration);
  if (fixed > 0) return fixed;
  const length = Array.from(el.getClientRects()).reduce((sum, r) => sum + r.width, 0);
  return Math.min(PACE.max, Math.max(PACE.min, length / PACE.speed));
}

function pace(el: HTMLElement, seconds: number, delay?: number) {
  el.style.setProperty("--hl-in", `${seconds}s`);
  el.style.setProperty("--hl-out", `${Math.max(0.2, seconds * 0.6)}s`);
  if (delay !== undefined) el.style.setProperty("--hl-delay", `${delay}s`);
}

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/** Once when it scrolls into view. Scrolled past unseen counts as seen. */
function useSeen(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {
  const [seen, setSeen] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    const io = new IntersectionObserver(
      ([entry]) => {
        const above = entry.boundingClientRect.bottom < (entry.rootBounds?.top ?? 0);
        if (!entry.isIntersecting && !above) return;
        io.disconnect();
        setSeen(true);
      },
      { rootMargin: "0px 0px -15% 0px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [ref, enabled]);
  return seen;
}

const Group = createContext<{ on: boolean } | null>(null);

type Trigger = {
  /** Draw once when it scrolls into view, or only when `active` says so. */
  trigger?: "view" | "manual";
  /** Controls it from outside. true draws, false wipes back out. Wins over `trigger`. */
  active?: boolean;
};

export type HighlightProps = Omit<React.HTMLAttributes<HTMLElement>, "children"> &
  Trigger & {
    children: React.ReactNode;
    /** A full-height marker behind the text, or a low stroke under it. */
    variant?: "marker" | "underline";
    /** Neutral emphasis, or a meaning: attention, added, removed, a note. */
    tone?: keyof typeof tones;
    /** Seconds before drawing. Ignored inside a HighlightGroup, which sequences for you. */
    delay?: number;
    /** Seconds to draw. By default it follows the length of the stroke. */
    duration?: number;
    /** Called when it has finished drawing in. */
    onDrawn?: () => void;
    ref?: React.Ref<HTMLElement>;
  };

/**
 * A marker stroke drawn behind text, left to right in reading order. It is a
 * background on the inline element, not a box behind it, so when the phrase
 * wraps the stroke finishes the first line and carries on along the next.
 */
export function Highlight({
  children,
  trigger = "view",
  active,
  variant = "marker",
  tone = "neutral",
  delay = 0,
  duration,
  onDrawn,
  className,
  style,
  ref,
  ...rest
}: HighlightProps) {
  const group = use(Group);
  const self = useRef<HTMLElement>(null);
  const seen = useSeen(self, !group && active === undefined && trigger === "view");
  const on = group ? group.on : (active ?? (trigger === "view" && seen));
  const drawn = useRef(onDrawn);
  useEffect(() => {
    drawn.current = onDrawn;
  });

  // Paced while it's off, ready for the next draw: measuring after it turns on
  // would flush styles and start the transition before its timing is set. The
  // stroke's length doesn't depend on its state. In a group, the group paces.
  useIsoLayoutEffect(() => {
    const el = self.current;
    if (!el) return;
    if (!on && !group) pace(el, strokeTime(el));
    // With reduced motion nothing transitions, so there is no transitionend to wait for.
    if (on && reducedMotion()) drawn.current?.();
  }, [on, duration, group]);

  return (
    <>
      <mark
        ref={(node: HTMLElement | null) => {
          self.current = node;
          if (typeof ref === "function") return ref(node);
          if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;
        }}
        data-highlight=""
        data-state={on ? "on" : "off"}
        data-variant={variant}
        data-tone={tone}
        data-duration={duration}
        onTransitionEnd={(e) => {
          if (e.target === e.currentTarget && e.propertyName === "background-size" && on) drawn.current?.();
        }}
        className={cn(
          // Zero-specificity reset of the browser's black-on-yellow, so any colour class you pass wins.
          "bg-transparent [:where(&)]:text-inherit",
          tones[tone],
          variant === "underline" && tone === "neutral" && "[--hl:color-mix(in_oklab,var(--fg)_30%,transparent)]",
          // Slice (the default) lays the background out along the whole phrase as if it were
          // one line, which is what makes a growing width draw line after line in order.
          "bg-[linear-gradient(var(--hl),var(--hl))] bg-no-repeat [box-decoration-break:slice]",
          variant === "marker"
            ? "-mx-[0.12em] rounded-[0.2em] px-[0.12em] [background-position:0_50%] data-[state=off]:[background-size:0%_100%] data-[state=on]:[background-size:100%_100%]"
            : "[background-position:0_90%] data-[state=off]:[background-size:0%_0.36em] data-[state=on]:[background-size:100%_0.36em]",
          // Draws in on the soft ease-out after its delay; wipes back out faster, all at once.
          // Colour rides along so a data-[state=on]: text colour eases with the stroke.
          "transition-[background-size,color] ease-out-quart duration-(--hl-out,0.3s) data-[state=on]:delay-(--hl-delay,0s) data-[state=on]:duration-(--hl-in,0.6s)",
          "motion-reduce:transition-none",
          className,
        )}
        style={group ? style : ({ "--hl-delay": `${delay}s`, ...style } as React.CSSProperties)}
        {...rest}
      >
        {children}
      </mark>
      {!group && trigger === "view" && active === undefined && (
        <noscript>
          <style>{"[data-highlight][data-state=off]{background-size:100% 100%!important}"}</style>
        </noscript>
      )}
    </>
  );
}

export type HighlightGroupProps = React.HTMLAttributes<HTMLElement> &
  Trigger & {
    as?: "div" | "p" | "section" | "article" | "span";
    /** Seconds before the first stroke. */
    delay?: number;
    /** Seconds between one stroke finishing and the next starting. */
    gap?: number;
    ref?: React.Ref<HTMLElement>;
  };

/**
 * Draws every Highlight inside it one after another, in reading order: each
 * stroke starts as the one before it finishes, whatever their lengths. Wiping
 * out happens all at once.
 */
export function HighlightGroup({ as: Tag = "div", trigger = "view", active, delay = 0, gap = 0.06, className, children, ref, ...rest }: HighlightGroupProps) {
  const self = useRef<HTMLElement>(null);
  const seen = useSeen(self, active === undefined && trigger === "view");
  const on = active ?? (trigger === "view" && seen);

  // Pace and chain the marks while they're off, ready for the next draw (see Highlight).
  useIsoLayoutEffect(() => {
    const el = self.current;
    if (!el || on) return;
    let at = delay;
    for (const mark of el.querySelectorAll<HTMLElement>("[data-highlight]")) {
      const seconds = strokeTime(mark);
      pace(mark, seconds, at);
      at += seconds + gap;
    }
  }, [on, delay, gap]);

  return (
    <Group value={{ on }}>
      <Tag
        ref={(node: HTMLElement | null) => {
          self.current = node;
          if (typeof ref === "function") return ref(node);
          if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;
        }}
        data-highlight-group=""
        data-state={on ? "on" : "off"}
        className={className}
        {...rest}
      >
        {children}
      </Tag>
      {trigger === "view" && active === undefined && (
        <noscript>
          <style>{"[data-highlight-group] [data-highlight]{background-size:100% 100%!important}"}</style>
        </noscript>
      )}
    </Group>
  );
}

05Props

Highlight

PropTypeDefaultDescription
children*ReactNodeThe phrase to mark. It can wrap across as many lines as it likes.
trigger"view" | "manual""view"Draw once when scrolled into view, or only when active says so.
activebooleanControl it: true draws it in, false wipes it back out. Wins over trigger.
variant"marker" | "underline""marker"A full-height marker behind the text, or a low stroke under it.
tone"neutral" | "warning" | "success" | "danger" | "info""neutral"Plain emphasis, or a meaning: attention, added, removed, a note.
delaynumber0Seconds before drawing. Ignored inside a HighlightGroup.
durationnumberSeconds to draw. By default it follows the stroke's length: 520px a second, 0.4s to 1.2s.
onDrawn() => voidCalled when it finishes drawing in.

HighlightGroup

PropTypeDefaultDescription
trigger"view" | "manual""view"Draw every mark inside once on first view, or only when active says so.
activebooleanControl every mark inside at once.
delaynumber0Seconds before the first stroke.
gapnumber0.06Seconds between one stroke finishing and the next starting.
as"div" | "p" | "section" | "article" | "span""div"The element to render.

06Notes

Behavior

  • The stroke is a background on the inline mark laid out as one continuous line, so a phrase that wraps draws to the end of its first line and carries on along the next, in reading order, at any width.
  • Duration follows the stroke's real length across all its lines, like a pen at a steady pace; a group chains its marks so each starts as the one before finishes, whatever their lengths.
  • Turning it off wipes the stroke back toward where it started, faster than it drew and all at once; interrupting either way reverses from where it is.
  • Pass a data-[state=on]: text colour to lift the marked words while they're marked; the colour eases with the stroke.

Motion

  • Draws on the soft ease-out quart at 520px a second (0.4s to 1.2s); wipes out in 60% of that, at least 200ms.
  • Timing is measured while the mark is off, before the frame that starts it, so the first draw is paced as correctly as the tenth.
  • Reduced motion shows or removes the mark instantly. Without JavaScript, marks that would draw on view are shown.

Accessibility

  • A real mark element, so the phrase is exposed as highlighted text where assistive tech supports it, and reads in place with its sentence.
  • Tones are colour only: say what the marks mean nearby, as the demo's heading and count do, never with colour alone.
  • Tone colours sit at 22–26% behind full-contrast text, so marked words stay above 4.5:1 in both themes.