Markierung
Eine Unterstreichung, ein Textmarker oder ein Kreis zeichnet sich hinter eine Wendung, als SVG in der Akzentfarbe. Die Wörter bleiben reiner Text.
Eine von 5 Components in Textakzente
Details
Was Markierung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Textakzente
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/highlight-marker.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle HighlightMarkerProps aus components/ui/highlight-marker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
childrenPflichtReact.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.
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 178 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Markierung?
Mit aiw add @ai-website/highlight-marker. Der Befehl legt die Datei components/ui/highlight-marker.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Markierung?
4 Varianten, Unterstrichen, Textmarker, Eingekreist und Im Fließtext. Du wählst sie mit der Prop variant, zum Beispiel variant="circle".
Kann ein KI-Assistent Markierung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Markierung mit aiw_components und installieren sie mit aiw_add @ai-website/highlight-marker. Nenn im Prompt die Variante, etwa variant="marker", dann schreibt der Assistent deine Inhalte direkt in die Props.