Skip to content
Corsair UI
Browse the docs

TextNo. 67 of 109

Highlight Text

A marker that slides in behind a phrase, or a hand-drawn line drawn under it.

Installation

pnpm dlx shadcn@latest add @corsair-ui/highlight-text

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsuse-in-viewuse-entrance
npm
clsxtailwind-merge

Examples

Inverted

The default marker is the foreground colour and the text inverts under it.

Every item installs as source, so it is yours to edit from the first commit.

Coloured markers

inverse={false} with markerClassName="bg-brass/40".

Plot the course in brass, and mark the shallows in sea blue.

Scribble

variant="scribble" draws a hand-drawn line under the phrase; scribble="double" draws two.

Stow the powder below the waterline, and keep the lanterns well away from it.

API

PropTypeDefault
variant

A block behind the text, or a hand-drawn line under it.

"marker" | "scribble""marker"
scribble / strokeWidth

Scribble: one line or two, and their width.

"underline" | "double" / number"underline" / 3
from

The side the marker slides in from.

"left" | "right" | "top" | "bottom""bottom"
inverse

Invert the text under the marker. Turn off for translucent markers.

booleantrue
markerClassName

Classes for the marker or the scribble, e.g. its colour.

string—
trigger / play

As in blur-text.

"load" | "in-view" / boolean"load"
delay / duration

In ms.

number0 / 600

Accessibility

  • The marker is aria-hidden; the text stays plain text in the sentence.
  • Check the contrast of text over custom marker colours. With prefers-reduced-motion it holds still and shows the finished state.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")

Source

1 file, installed as source you own
highlight-text.tsxView on GitHub
"use client";

import { useMemo, type ComponentProps, type CSSProperties, type Ref } from "react";

import { useEntrance, type EntranceTrigger } from "@/registry/default/hooks/use-entrance";
import { cn } from "@/registry/default/lib/utils";

function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
  return (node: T | null) => {
    for (const ref of refs) {
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    }
  };
}

const FROM = {
  left: "translate3d(-101%, 0, 0)",
  right: "translate3d(101%, 0, 0)",
  top: "translate3d(0, -101%, 0)",
  bottom: "translate3d(0, 101%, 0)",
};

// Hand-drawn strokes in a box 100 wide and `height` tall; the svg stretches
// them across the phrase. The inset keeps round caps inside the box.
function scribblePaths(shape: "underline" | "double", height: number, inset: number) {
  const y = (fraction: number) => Math.round((inset + fraction * (height - inset * 2)) * 100) / 100;
  if (shape === "double") {
    return [
      `M1 ${y(0.3)} C 22 ${y(0)}, 48 ${y(0.45)}, 70 ${y(0.2)} S 94 ${y(0.1)}, 99 ${y(0.25)}`,
      `M4 ${y(0.9)} C 28 ${y(0.65)}, 55 ${y(1)}, 78 ${y(0.75)} S 96 ${y(0.7)}, 98 ${y(0.8)}`,
    ];
  }
  return [`M1 ${y(0.7)} C 18 ${y(0.1)}, 32 ${y(1)}, 50 ${y(0.5)} S 82 ${y(0)}, 99 ${y(0.45)}`];
}

interface HighlightTextProps extends ComponentProps<"span"> {
  /**
   * "marker" slides a block of colour in behind the text; "scribble" draws a
   * hand-drawn line under it and leaves the text as it is.
   */
  variant?: "marker" | "scribble";
  /** Scribble: one wavy underline, or two strokes. */
  scribble?: "underline" | "double";
  /** Scribble: width of the line, in px. */
  strokeWidth?: number;
  /** Marker: the side the marker slides in from. */
  from?: keyof typeof FROM;
  /**
   * Invert the text where the marker covers it, with a difference blend.
   * Turn it off for a translucent marker (`markerClassName="bg-yellow-300/50"`)
   * that leaves the text colour alone.
   */
  inverse?: boolean;
  /**
   * Classes for the marker or the scribble, e.g. its colour. The marker
   * defaults to the foreground colour; the scribble to
   * `--highlight-text-color`, else the text colour (`text-primary` changes it).
   */
  markerClassName?: string;
  /** "load" plays on first paint with CSS alone; "in-view" when the text scrolls into view. */
  trigger?: EntranceTrigger;
  /** In-view: play the first time only. */
  once?: boolean;
  /** Takes over from `trigger`: `false` holds the marker out of sight, `true` plays it. */
  play?: boolean;
  /** Wait before the marker moves or the line starts, in ms. */
  delay?: number;
  /** How long the marker or the line takes, in ms. */
  duration?: number;
}

/**
 * A marker stroke that slides in behind a word or phrase. By default it is
 * the foreground colour and the text inverts where it passes, so it works
 * in light and dark themes without picking colours. Use it inline; it keeps
 * the baseline of the surrounding text. With `variant="scribble"` a
 * hand-drawn line is drawn under the phrase instead, and the text keeps its
 * colour. Both are decorative; the text reads the same to screen readers.
 * With `prefers-reduced-motion` the marker or line is simply there.
 *
 * @example
 * <p>Every item is <HighlightText>yours to edit</HighlightText>.</p>
 *
 * @example
 * <h2>
 *   Built to <HighlightText variant="scribble" markerClassName="text-primary">last</HighlightText>
 * </h2>
 */
function HighlightText({
  variant = "marker",
  scribble = "underline",
  strokeWidth = 3,
  from = "bottom",
  inverse = true,
  markerClassName,
  trigger = "load",
  once = true,
  play,
  delay = 0,
  duration = 600,
  className,
  style,
  ref,
  children,
  ...props
}: HighlightTextProps) {
  const [observe, phase] = useEntrance<HTMLSpanElement>({ trigger, once, play });
  const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);

  if (variant === "scribble") {
    const width = Math.max(strokeWidth, 0);
    // The box is sized in px, so the line keeps its width however the phrase stretches it.
    const height = Math.round((scribble === "double" ? 12 : 8) + width * 2);
    const strokes = scribblePaths(scribble, height, width / 2 + 0.5);
    // Two strokes share the time: the second starts as the first ends.
    const share = strokes.length > 1 ? 0.55 : 1;
    return (
      <span
        ref={mergedRef}
        data-slot="highlight-text"
        data-variant="scribble"
        data-state={phase}
        className={cn("relative inline-flex px-[0.15em] align-baseline", className)}
        style={style}
        {...props}
      >
        <svg
          aria-hidden="true"
          data-slot="highlight-text-scribble"
          viewBox={`0 0 100 ${height}`}
          preserveAspectRatio="none"
          fill="none"
          stroke="currentColor"
          strokeWidth={width}
          strokeLinecap="round"
          strokeLinejoin="round"
          className={cn(
            "pointer-events-none absolute inset-x-0 top-[calc(100%-0.2em)] w-full overflow-visible text-[color:var(--highlight-text-color,currentColor)]",
            markerClassName
          )}
          style={{ height: `${height}px` }}
        >
          {strokes.map((d, index) => (
            <path
              key={index}
              d={d}
              pathLength={1}
              className={cn(phase !== "static" && "motion-safe:animate-highlight-text-draw")}
              style={{
                strokeDasharray: "1 2",
                strokeDashoffset: 0,
                ...(phase === "static"
                  ? null
                  : {
                      animationDelay: `${Math.round(delay + index * (1 - share) * duration)}ms`,
                      animationDuration: `${Math.round(share * duration)}ms`,
                      animationPlayState: phase === "armed" ? "paused" : undefined,
                    }),
              }}
            />
          ))}
        </svg>
        <span className="relative">{children}</span>
      </span>
    );
  }

  return (
    <span
      ref={mergedRef}
      data-slot="highlight-text"
      data-variant="marker"
      data-state={phase}
      // inline-flex keeps the text baseline, which an inline-block with clipping would lose.
      className={cn("relative inline-flex overflow-clip px-[0.15em] align-baseline", className)}
      style={style}
      {...props}
    >
      <span
        aria-hidden="true"
        data-slot="highlight-text-marker"
        className={cn(
          "bg-foreground absolute inset-0",
          phase !== "static" && "motion-safe:animate-highlight-text",
          markerClassName
        )}
        style={
          phase === "static"
            ? undefined
            : ({
                "--highlight-text-from": FROM[from],
                animationDelay: `${delay}ms`,
                animationDuration: `${duration}ms`,
                animationPlayState: phase === "armed" ? "paused" : undefined,
              } as CSSProperties)
        }
      />
      <span className={cn("relative", inverse && "text-white mix-blend-difference")}>
        {children}
      </span>
    </span>
  );
}

export { HighlightText, type HighlightTextProps };