Skip to content
UI Kit
Accent color

Highlight marker

An underline, a highlighter or a loop draws itself behind a phrase, as SVG in the accent color. The words stay plain text.

One of 5 components in Text accents

4 variants

Variants

Underline

A hand drawn stroke below the phrase, its width follows the font size.

Marker

A highlighter draws behind the lower half of the words as you scroll, and wipes back when you scroll up.

Circle

One loop around the one word that matters, drawn in a single stroke.

In running text

Small sizes inside a paragraph: a quiet marker and an accent underline that draws again on hover.

Details

What Highlight marker brings along, read from the registry and the source.

Category
Text accents
Variants
4
Version
1.0.0 · since 1.0.0
File
components/ui/highlight-marker.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The HighlightMarkerProps interface from components/ui/highlight-marker.tsx, with the comments from the code.

  • childrenrequired
    React.ReactNode

    The phrase, short (one to three words). It stays on one line.

  • variantoptional
    HighlightMarkerVariant

    underlinemarkercircle

    underline: a hand drawn stroke below (default). marker: a highlighter behind the lower half. circle: a loop around.

  • triggeroptional
    HighlightMarkerTrigger

    loadviewin-viewhoverscroll

    load: right away. view (or in-view): when it scrolls in (default). hover: again on every hover and focus. scroll: the stroke draws along with the scroll position and goes back when you scroll up.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that draws the stroke, 0 to 1. Default [0.1, 0.5].

  • toneoptional
    'accent' | 'muted'

    accent: the accent color (default). muted: a quiet grey.

  • delayoptional
    number

    Milliseconds before the stroke starts, so the words are read first. Default 350.

  • durationoptional
    number

    Milliseconds the stroke takes. Default 800, circle 1000.

Accessibility

Read from the source of the component.

  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 178 lines
components/ui/highlight-marker.tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

export type HighlightMarkerVariant = 'underline' | 'marker' | 'circle';
/** in-view is the same as view */
export type HighlightMarkerTrigger = 'load' | 'view' | 'in-view' | 'hover' | 'scroll';

export interface HighlightMarkerProps {
  /** The phrase, short (one to three words). It stays on one line. */
  children: React.ReactNode;
  /** underline: a hand drawn stroke below (default). marker: a highlighter behind the lower half. circle: a loop around. */
  variant?: HighlightMarkerVariant;
  /**
   * load: right away. view (or in-view): when it scrolls in (default).
   * hover: again on every hover and focus. scroll: the stroke draws along
   * with the scroll position and goes back when you scroll up.
   */
  trigger?: HighlightMarkerTrigger;
  /** With trigger scroll: the part of the way through the window that draws the stroke, 0 to 1. Default [0.1, 0.5]. */
  scrollRange?: [number, number];
  /** accent: the accent color (default). muted: a quiet grey. */
  tone?: 'accent' | 'muted';
  /** Milliseconds before the stroke starts, so the words are read first. Default 350. */
  delay?: number;
  /** Milliseconds the stroke takes. Default 800, circle 1000. */
  duration?: number;
  className?: string;
}

/*
 * The phrase is an inline box on one line, so the drawing can sit on it in
 * em: the box is the font's content area whatever the line height, the
 * baseline sits near 0.97em. The SVG stretches to the phrase and the stroke
 * keeps its width (non-scaling-stroke). Underline and marker are revealed by
 * a clip from left to right, the circle by a conic mask that runs around
 * its center, so the loop draws itself in one go without measuring.
 */
const CSS = `
@property --kitfx-hl-a{syntax:"<angle>";inherits:false;initial-value:400deg}
.kitfx-hl{position:relative;white-space:nowrap;isolation:isolate}
.kitfx-hl-svg{position:absolute;z-index:-1;overflow:visible;pointer-events:none}
.kitfx-hl[data-variant=underline] .kitfx-hl-svg{left:-.04em;width:calc(100% + .08em);top:.96em;height:.32em}
.kitfx-hl[data-variant=marker] .kitfx-hl-svg{left:-.12em;width:calc(100% + .24em);top:.46em;height:.56em}
.kitfx-hl[data-variant=circle]{margin-inline:.1em;line-height:1.24}
.kitfx-hl[data-variant=circle] .kitfx-hl-svg{left:-.28em;width:calc(100% + .56em);top:-.08em;height:1.36em;-webkit-mask-image:conic-gradient(from -30deg,#000 calc(var(--kitfx-hl-a) - 30deg),transparent var(--kitfx-hl-a));mask-image:conic-gradient(from -30deg,#000 calc(var(--kitfx-hl-a) - 30deg),transparent var(--kitfx-hl-a))}
.kitfx-hl-stroke{fill:none;stroke:var(--kitfx-hl-ink);stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke}
.kitfx-hl[data-variant=underline] .kitfx-hl-stroke{stroke-width:max(2px,.075em)}
.kitfx-hl[data-variant=circle] .kitfx-hl-stroke{stroke-width:max(1.75px,.055em)}
.kitfx-hl-fill{fill:var(--kitfx-hl-fill)}
.kitfx-hl[data-tone=accent]{--kitfx-hl-ink:color-mix(in oklch,var(--primary) 88%,var(--foreground));--kitfx-hl-fill:color-mix(in oklch,var(--primary) 22%,transparent)}
.kitfx-hl[data-tone=muted]{--kitfx-hl-ink:var(--subtle-foreground);--kitfx-hl-fill:color-mix(in oklch,var(--foreground) 17%,transparent)}
.kitfx-hl[data-mode=play] .kitfx-hl-svg{animation:kitfx-hl-wipe var(--kitfx-hl-dur) cubic-bezier(.6,.1,.3,1) var(--kitfx-hl-delay) both}
.kitfx-hl[data-mode=play][data-variant=circle] .kitfx-hl-svg{animation-name:kitfx-hl-around;animation-timing-function:cubic-bezier(.45,.1,.3,1)}
.kitfx-hl[data-mode=wait] .kitfx-hl-svg{clip-path:inset(-50% 100% -50% -5%)}
.kitfx-hl[data-mode=wait][data-variant=circle] .kitfx-hl-svg{clip-path:none;--kitfx-hl-a:0deg}
.kitfx-hl[data-mode=scroll] .kitfx-hl-svg{clip-path:inset(-50% calc(105% - var(--kitfx-p,1) * 110%) -50% -5%);transition:clip-path .2s linear}
.kitfx-hl[data-mode=scroll][data-variant=circle] .kitfx-hl-svg{clip-path:none;--kitfx-hl-a:calc(var(--kitfx-p,1) * 400deg);transition:--kitfx-hl-a .2s linear}
@keyframes kitfx-hl-wipe{from{clip-path:inset(-50% 100% -50% -5%)}to{clip-path:inset(-50% -5% -50% -5%)}}
@keyframes kitfx-hl-around{from{--kitfx-hl-a:0deg}to{--kitfx-hl-a:400deg}}
@keyframes kitfx-hl-fade{from{opacity:0}}
@media (prefers-reduced-motion:reduce){.kitfx-hl[data-mode] .kitfx-hl-svg{animation:kitfx-hl-fade .6s ease both;clip-path:none;--kitfx-hl-a:400deg;transition:none}}`;

/** Plays a CSS entrance from the first paint, holds phrases further down until they scroll in. */
function usePlay<T extends HTMLElement>(trigger: HighlightMarkerTrigger) {
  const ref = useRef<T>(null);
  const [run, setRun] = useState(0);
  const [mode, setMode] = useState<'play' | 'wait' | 'idle' | 'scroll'>(trigger === 'hover' ? 'idle' : trigger === 'scroll' ? 'scroll' : 'play');
  useEffect(() => {
    const el = ref.current;
    if (!el || (trigger !== 'view' && trigger !== 'in-view') || prefersReducedMotion()) return;
    if (el.getBoundingClientRect().top < window.innerHeight * 0.9) return;
    setMode('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setMode('play');
        setRun((r) => r + 1);
        io.disconnect();
      },
      { rootMargin: '0px 0px -15% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [trigger]);
  const replay = useCallback(() => {
    if (prefersReducedMotion()) return;
    setMode('play');
    setRun((r) => r + 1);
  }, []);
  return { ref, run, mode, replay };
}

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
  const [start, end] = range;
  const listener = useRef(onChange);
  listener.current = onChange;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
      listener.current?.(p);
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}

function Drawing({ variant }: { variant: HighlightMarkerVariant }) {
  if (variant === 'marker')
    return (
      <svg className="kitfx-hl-svg" viewBox="0 0 200 40" preserveAspectRatio="none" aria-hidden focusable="false">
        <path className="kitfx-hl-fill" d="M3.5 9.5 C 30 5.5, 62 7, 96 5 S 160 3.5, 197 6.5 L 196 31 C 170 34.5, 132 33, 98 35.5 S 30 36.5, 4.5 33.5 Z" />
      </svg>
    );
  if (variant === 'circle')
    return (
      <svg className="kitfx-hl-svg" viewBox="0 0 200 60" preserveAspectRatio="none" aria-hidden focusable="false">
        <path className="kitfx-hl-stroke" d="M118 5.5 C 66 3.2, 30 4.5, 11 11.5 C 3 15.5, 3.5 24, 4.5 31 C 6 47, 34 56.5, 102 56.5 C 160 56.5, 194 50, 195.5 31 C 197 13, 168 3.5, 100 4.5 C 80 4.8, 60 6.6, 43 10" />
      </svg>
    );
  return (
    <svg className="kitfx-hl-svg" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden focusable="false">
      <path className="kitfx-hl-stroke" d="M3 15.5 C 38 10.5, 84 8, 128 9 C 156 9.6, 178 11.5, 197 9" />
    </svg>
  );
}

/**
 * A stroke draws itself behind a short phrase: an underline, a highlighter
 * or a loop around it, in the accent color. The words stay plain text in
 * their own color and on one line. Without JavaScript and with reduced
 * motion the drawing stands finished.
 *   <h2>Websites that <HighlightMarker variant="marker">sell</HighlightMarker></h2>
 */
export function HighlightMarker({ children, variant = 'underline', trigger = 'view', scrollRange = [0.1, 0.5], tone = 'accent', delay = 350, duration, className }: HighlightMarkerProps) {
  const { ref, run, mode, replay } = usePlay<HTMLSpanElement>(trigger);
  useScrollLink(ref, trigger === 'scroll', scrollRange);
  const dur = duration ?? (variant === 'circle' ? 1000 : 800);
  const vars = { '--kitfx-hl-dur': `${dur}ms`, '--kitfx-hl-delay': `${delay}ms` } as React.CSSProperties;
  const hover = trigger === 'hover' ? { onPointerEnter: replay, onFocus: replay } : {};
  return (
    <span ref={ref} className={cn('kitfx-hl', className)} data-variant={variant} data-tone={tone} data-mode={mode} style={vars} {...hover}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <Drawing key={run} variant={variant} />
      {children}
    </span>
  );
}

Frequent questions

How do I install Highlight marker?

With aiw add @ai-website/highlight-marker. The command puts components/ui/highlight-marker.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Highlight marker have?

4 variants, Underline, Marker, Circle and In running text. You pick one with the variant prop, for example variant="circle".

Can an AI assistant build with Highlight marker?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Highlight marker with aiw_components and install it with aiw_add @ai-website/highlight-marker. Name the variant in your prompt, for example variant="marker", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard