Skip to content

Asterisk Type Headline

A headline typed out by an asterisk: the caret spins fast while it types, opens an inline pill when it reaches one, then parks in the slot you mark with * (by default just after the last word) and keeps turning slowly.

FreeHolo Glossminimalglass
Preview

Props

46
No runtime dependencies
"use client";

import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";

/* ============================================================================
 * SHARED PIECES
 * ========================================================================== */

/** The kit's face. Urbanist is set at 300 for the quiet line, 700 for the loud one. */
export const HG_FONT = 'var(--font-urbanist, "Urbanist"), "Outfit", ui-sans-serif, system-ui, sans-serif';

export function hgReducedMotion(): boolean {
  return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/** True once the element has come within `margin` of the viewport. Never goes back. */
export function useHgSeen<T extends HTMLElement>(ref: RefObject<T | null>, margin = "200px"): boolean {
  const [seen, setSeen] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || seen) return;
    if (typeof IntersectionObserver === "undefined") {
      // No observer to wait for: count it as seen on the next frame.
      const id = requestAnimationFrame(() => setSeen(true));
      return () => cancelAnimationFrame(id);
    }
    // Watch against the box that scrolls the element when there is one: the
    // margin only reaches ahead inside its own root, so against the window
    // it would do nothing for a page living in a scrolling preview.
    const io = new IntersectionObserver(
      (es) => {
        if (es.some((e) => e.isIntersecting)) {
          setSeen(true);
          io.disconnect();
        }
      },
      { root: hgScrollParent(el), rootMargin: margin },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [ref, margin, seen]);
  return seen;
}

/**
 * The scrolling box an element lives in: the nearest ancestor that actually
 * scrolls, or null for the window. Resolved lazily on every scroll, because a
 * scroller only becomes one once its content is taller than it.
 */
export function hgScrollParent(el: Element | null): HTMLElement | null {
  let n = el?.parentElement ?? null;
  while (n && n !== document.body && n !== document.documentElement) {
    const oy = getComputedStyle(n).overflowY;
    if ((oy === "auto" || oy === "scroll" || oy === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
    n = n.parentElement;
  }
  return null;
}

/** The eight-spoke asterisk the kit uses as caret, bullet and spinner. */
export function HgAsterisk({ className, style, stroke = 1.6 }: { className?: string; style?: CSSProperties; stroke?: number }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden className={className} style={style} fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
      <path d="M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4" />
    </svg>
  );
}

/* ============================================================================
 * NAV + ASTERISK HEADLINE
 * ========================================================================== */

const HG_NAV_CSS = `
.hg-pill{position:relative;overflow:hidden;isolation:isolate;transition:color .45s cubic-bezier(.2,.7,.2,1),border-color .45s,background-color .45s}
.hg-pill::before{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:var(--hg-pill-fill);transform:translateY(102%);transition:transform .5s cubic-bezier(.2,.7,.2,1)}
.hg-pill:hover::before,.hg-pill:focus-visible::before{transform:none}
.hg-pill:hover,.hg-pill:focus-visible{color:var(--hg-pill-on)}
.hg-pill .hg-pill-star{width:0;opacity:0;margin-left:0;transition:width .45s cubic-bezier(.2,.7,.2,1),opacity .3s,margin .45s}
.hg-pill:hover .hg-pill-star,.hg-pill:focus-visible .hg-pill-star{width:.8em;opacity:1;margin-left:.45em;animation:hg-spin 2.4s linear infinite}
.hg-pill:focus-visible{outline:2px solid var(--hg-ring) !important;outline-offset:3px}
@keyframes hg-spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion: reduce){.hg-pill::before,.hg-pill .hg-pill-star{transition:none}.hg-pill:hover .hg-pill-star{animation:none}}
`;

type HgToken = { k: "ch"; ch: string; bold: boolean } | { k: "star"; bold: boolean } | { k: "pill" } | { k: "br" };

/**
 * Parse the headline markup: lines split on a newline, **bold** runs, a lone
 * * is the asterisk slot (where the caret parks), [pill] is the inline pill.
 */
export function hgParseHeadline(text: string): HgToken[] {
  const out: HgToken[] = [];
  let bold = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i]!;
    if (c === "*" && text[i + 1] === "*") {
      bold = !bold;
      i++;
      continue;
    }
    if (c === "*") {
      out.push({ k: "star", bold });
      continue;
    }
    if (c === "[" && text.startsWith("[pill]", i)) {
      out.push({ k: "pill" });
      i += 5;
      continue;
    }
    if (c === "\n") {
      out.push({ k: "br" });
      continue;
    }
    out.push({ k: "ch", ch: c, bold });
  }
  return out;
}

const HG_TYPE_CSS = `
.hg-tw [data-c]{opacity:0;transition:opacity .18s linear}
.hg-tw [data-c][data-on]{opacity:1}
.hg-tw .hg-tw-pill{display:inline-block;vertical-align:-0.06em;height:.72em;width:0;border-radius:999px;overflow:hidden;transition:width .9s cubic-bezier(.2,.75,.15,1),margin .9s cubic-bezier(.2,.75,.15,1)}
.hg-tw .hg-tw-pill[data-on]{width:var(--hg-pill-w,4.4em);margin:0 .12em 0 .28em}
.hg-tw .hg-tw-caret{position:absolute;left:0;top:0;width:.56em;height:.56em;color:var(--hg-caret,currentColor);pointer-events:none;transition:transform .32s cubic-bezier(.2,.7,.2,1),opacity .3s}
.hg-tw .hg-tw-caret svg{width:100%;height:100%;display:block;animation:hg-spin 12s linear infinite}
.hg-tw .hg-tw-caret[data-typing] svg{animation-duration:1.6s}
@media (prefers-reduced-motion: reduce){.hg-tw [data-c]{transition:none}.hg-tw .hg-tw-pill{transition:none}.hg-tw .hg-tw-caret{transition:none}.hg-tw .hg-tw-caret svg{animation:none}}
`;

/**
 * A headline typed out by an asterisk. The asterisk is the caret: it runs
 * ahead of the letters, spinning fast while it types, then settles into the
 * slot marked * (by default just after the last word, like a footnote mark)
 * and keeps turning slowly. A [pill] in the markup opens to its width when
 * the caret reaches it. Every letter of the copy is typed: the asterisk only
 * ever takes a slot of its own, never a letter's place.
 *
 * Every letter is laid out from the first frame and only its opacity
 * changes, so the line never reflows while it types.
 */
export function AsteriskTypeHeadline({
  text = "Switch up to [pill]\n**next generation** of interfaces.*",
  pill,
  pillWidth = "4.4em",
  as: Tag = "h1",
  speed = 46,
  delay = 450,
  play = true,
  caretColor,
  className,
  style,
}: {
  text?: string;
  pill?: ReactNode;
  pillWidth?: string;
  as?: "h1" | "h2" | "h3" | "p";
  /** Milliseconds per letter. */
  speed?: number;
  delay?: number;
  /** Set false to hold the headline blank (for example until a loader ends). */
  play?: boolean;
  caretColor?: string;
  className?: string;
  style?: CSSProperties;
}) {
  const tokens = useMemo(() => hgParseHeadline(text), [text]);
  const root = useRef<HTMLHeadingElement>(null);
  const caret = useRef<HTMLSpanElement>(null);
  const seen = useHgSeen(root, "0px");
  const plain = tokens.map((t) => (t.k === "ch" ? t.ch : t.k === "star" ? "" : t.k === "br" ? " " : "")).join("").replace(/\s+/g, " ").trim();
  useEffect(() => {
    const el = root.current;
    const cr = caret.current;
    if (!el || !cr) return;
    const items = Array.from(el.querySelectorAll<HTMLElement>("[data-c],[data-pill],[data-star]"));
    const place = (target: HTMLElement | null, after: boolean) => {
      if (!target) return;
      const a = el.getBoundingClientRect();
      const b = target.getBoundingClientRect();
      // The slot and the pill have no glyph box of their own, so the line's
      // vertical metrics come from the nearest letter.
      const line = target.closest("span.block") ?? target;
      const ref = line.querySelector<HTMLElement>("[data-c]") ?? target;
      const v = ref.getBoundingClientRect();
      const s = cr.offsetWidth;
      // Rects are in screen pixels, the caret moves in the headline's own:
      // divide by the scale an ancestor transform applies (a scaled preview
      // card, a zoomed stage) so the caret lands on its slot at any size.
      const k = el.offsetWidth > 0 && a.width > 0 ? a.width / el.offsetWidth : 1;
      const x = after ? (b.right - a.left) / k + s * 0.2 : (b.left - a.left + b.width / 2) / k - s / 2;
      // Centre on the x-height: a little below the middle of the glyph box.
      const y = (v.top - a.top + v.height * 0.56) / k - s / 2;
      cr.style.transform = "translate(" + x + "px," + y + "px)";
    };
    const star = el.querySelector<HTMLElement>("[data-star]");
    if (hgReducedMotion() || !play || !seen) {
      const show = hgReducedMotion();
      items.forEach((n) => (show ? n.setAttribute("data-on", "") : n.removeAttribute("data-on")));
      cr.style.opacity = show ? "1" : "0";
      cr.removeAttribute("data-typing");
      if (show) place(star ?? items[items.length - 1] ?? null, !star);
      return;
    }
    items.forEach((n) => n.removeAttribute("data-on"));
    cr.style.transition = "none";
    place(items[0] ?? null, false);
    cr.style.opacity = "1";
    void cr.offsetWidth;
    cr.style.transition = "";
    cr.setAttribute("data-typing", "");
    let i = 0;
    let timer = 0;
    const tick = () => {
      const n = items[i];
      if (!n) {
        cr.removeAttribute("data-typing");
        place(star ?? items[items.length - 1] ?? null, !star);
        return;
      }
      n.setAttribute("data-on", "");
      // The caret sits just after what has been typed. A pill takes longer
      // to open, so the caret waits for it.
      place(n, true);
      i++;
      const ch = n.textContent ?? "";
      timer = window.setTimeout(tick, n.hasAttribute("data-pill") ? 520 : ch === " " ? speed * 0.6 : speed * (0.7 + Math.random() * 0.6));
    };
    timer = window.setTimeout(tick, delay);
    // The headline's box can change without the window changing (a
    // container query, a font swap), so watch the element itself.
    const onResize = () => {
      if (i >= items.length) place(star ?? items[items.length - 1] ?? null, !star);
    };
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(onResize) : null;
    ro?.observe(el);
    document.fonts?.ready.then(onResize);
    return () => {
      window.clearTimeout(timer);
      ro?.disconnect();
    };
  }, [tokens, play, seen, speed, delay]);
  let line: ReactNode[] = [];
  const lines: ReactNode[][] = [];
  tokens.forEach((t, i) => {
    if (t.k === "br") {
      lines.push(line);
      line = [];
      return;
    }
    if (t.k === "pill") {
      line.push(
        <span key={i} data-pill className="hg-tw-pill" style={{ "--hg-pill-w": pillWidth } as CSSProperties}>
          {pill}
        </span>,
      );
      return;
    }
    if (t.k === "star") {
      line.push(<span key={i} data-star style={{ display: "inline-block", width: "0.6em", fontWeight: t.bold ? 700 : 300 }} />);
      return;
    }
    line.push(
      <span key={i} data-c style={{ fontWeight: t.bold ? 700 : 300 }}>
        {t.ch === " " ? String.fromCharCode(160) : t.ch}
      </span>,
    );
  });
  lines.push(line);
  return (
    <Tag
      ref={root as RefObject<HTMLHeadingElement>}
      aria-label={plain}
      className={"hg-tw relative " + (className ?? "")}
      style={{ fontFamily: HG_FONT, letterSpacing: "-0.035em", lineHeight: 1.12, ...style }}
    >
      <style>{HG_NAV_CSS + HG_TYPE_CSS}</style>
      {lines.map((l, i) => (
        <span key={i} aria-hidden className="block whitespace-nowrap">
          {l}
        </span>
      ))}
      <span ref={caret} aria-hidden className="hg-tw-caret" style={{ opacity: 0, ...(caretColor ? ({ "--hg-caret": caretColor } as CSSProperties) : null) }}>
        <HgAsterisk stroke={2.4} />
      </span>
    </Tag>
  );
}

Free, yours to ship. Pro adds the heroes, WebGL backgrounds and complete style kits of Holo Gloss, from $6/month (launch price until 31 Oct).

Notes

About this text effect

A typewriter effect with one idea in it: the caret is a glyph that belongs to the headline. Write the line with a little markup: **double asterisks** for the bold run, a single * where the caret should finally settle, [pill] for an inline capsule, and a newline for a second line. The asterisk then types the line letter by letter with a small human jitter in the rhythm, spinning quickly as it goes. When it reaches the pill, the pill opens to its width and the caret waits for it. When the last letter lands, the caret glides to the slot you marked and stays there like a footnote mark, turning slowly. The slot is a space of its own, so every letter of your copy is always typed in full. Because all the letters are laid out from the start, nothing reflows, the line breaks are final from the first frame, and the effect is cheap: one timer and one transform.

Curator’s note

Every letter is laid out from the first frame and only its opacity changes, so the line never reflows as it types; the caret is one absolutely positioned glyph moved by transform to just after the last letter shown.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Typewriter Text

text effects

Text that types itself out character by character, then cycles to the next phrase.

typewritertextcycle

Glass Etch Text

text effects

Real, selectable heading text rendered as glass: a lit bevel, a graduated rim and a hairline colour fringe, raised or etched in.

text effectglasssvg filter
Pro

A rotating word wheel built on a real cylinder — derived radius, accumulated rotation, no backwards spin on the wrap.

type3dcarousel