Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Unterstrichen

Ein handgezogener Strich unter der Wendung, seine Stärke folgt der Schriftgröße.

Textmarker

Ein Textmarker zieht beim Scrollen hinter die untere Hälfte der Wörter und weicht zurück, wenn du hochscrollst.

Eingekreist

Ein Kreis um das eine Wort, auf das es ankommt, in einem Zug gezeichnet.

Im Fließtext

Kleine Größen im Absatz: ein ruhiger Marker und eine Akzentlinie, die sich bei Hover neu zeichnet.

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

Props

Die Schnittstelle HighlightMarkerProps aus components/ui/highlight-marker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • childrenPflicht
    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.

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
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>
  );
}

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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert