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
Details
What Highlight marker brings along, read from the registry and the source.
- Category
- Text accents
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The HighlightMarkerProps interface from components/ui/highlight-marker.tsx, with the comments from the code.
childrenrequiredReact.ReactNodeThe phrase, short (one to three words). It stays on one line.
variantoptionalHighlightMarkerVariantunderlinemarkercircle
underline: a hand drawn stroke below (default). marker: a highlighter behind the lower half. circle: a loop around.
triggeroptionalHighlightMarkerTriggerloadviewin-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.
delayoptionalnumberMilliseconds before the stroke starts, so the words are read first. Default 350.
durationoptionalnumberMilliseconds 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
'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.