Truncation Taper Fade

Text

Truncation without the ellipsis — an overflowing line's trailing characters crowd tighter and dissolve toward the clip edge instead of getting cut to '...'; hover or focus decompresses it to read the tail.

Install
npx shadcn add https://design.helpmarq.com/r/truncation-taper-fade.json
Source
registry/core/truncation-taper-fade/component.tsx
"use client";

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";

// Truncation without the ellipsis. Instead of a hard overflow:hidden cut (or
// a hidden dependency on a wdth-axis font Geist Sans doesn't ship — checked:
// its fvar table has only `wght`, no `wdth`), the trailing run of characters
// nearest the clip edge crowds itself: negative letter-spacing pulls them
// together (this is the part that actually reclaims layout width — the same
// job font-stretch/wdth would do on a font that had the axis), a matching
// horizontal scaleX squeeze reinforces the narrowing by eye, and a
// mask-image fade dissolves the final few characters toward transparent —
// alpha, not a background-colored overlay, so it's correct against any
// surface without needing to sample one. font-stretch is still set
// alongside letter-spacing (harmless no-op today, forward-compatible if a
// variable font with a real width axis is swapped in later).
//
// The compaction is heavier the more text is actually hidden: `hiddenRatio`
// (overflow px / container width) drives how far letter-spacing, scaleX and
// font-stretch travel, so a lightly-clipped cell barely tapers and a badly
// clipped one visibly crushes at its edge — the still frame communicates
// overflow magnitude, not just its existence.
//
// Full text is always real DOM text (never aria-hidden decoration standing
// in for a label) — mask and width axis are paint-only, so a screen reader
// reads the complete string regardless of visual state. The element also
// carries `title` and is a tab stop; focus (and hover) spring the tapered
// run back to full width, and — only if the line still doesn't fit even
// fully expanded — glide the whole line left to peek the tail, hold, then
// return. The admitted cost: a keyboard user has to focus each cell to read
// a tail, exactly the cost of native ellipsis + a tooltip.

const SPRING_MS = 220;
const PEEK_HOLD_MS = 900;
const PEEK_TRAVEL_MS = 650;
const MAX_TAIL_CHARS = 12;

function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}

interface Metrics {
  isOverflowing: boolean;
  overflowPx: number;
  hiddenRatio: number;
  tailStart: number;
  lastVisibleIndex: number;
}

const REST_METRICS: Metrics = {
  isOverflowing: false,
  overflowPx: 0,
  hiddenRatio: 0,
  tailStart: 0,
  lastVisibleIndex: 0,
};

export interface VanishTaperProps {
  /** Full string — always rendered as real text, in full, regardless of visual clipping. */
  text: string;
  /** How many trailing characters can taper at most. Default 12. */
  maxTailChars?: number;
  className?: string;
}

export function VanishTaper({ text, maxTailChars = MAX_TAIL_CHARS, className = "" }: VanishTaperProps) {
  const containerRef = useRef<HTMLSpanElement>(null);
  const measurerRef = useRef<HTMLSpanElement>(null);
  const [metrics, setMetrics] = useState<Metrics>(REST_METRICS);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [peeking, setPeeking] = useState(false);
  const [reducedMotion, setReducedMotion] = useState(false);

  const active = hovered || focused;
  const chars = useMemo(() => Array.from(text), [text]);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReducedMotion(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  // Measure natural (untapered) width via a hidden sibling clone, compare to
  // the container's clipped width, and estimate where the visible boundary
  // falls so the taper zone sits near the actual clip edge rather than
  // always at the literal end of a string many times longer than the box.
  useLayoutEffect(() => {
    const container = containerRef.current;
    const measurer = measurerRef.current;
    if (!container || !measurer) return;

    const measure = () => {
      const containerWidth = container.clientWidth;
      const naturalWidth = measurer.scrollWidth;
      if (containerWidth <= 0 || chars.length === 0) {
        setMetrics(REST_METRICS);
        return;
      }
      const isOverflowing = naturalWidth > containerWidth + 0.5;
      const overflowPx = Math.max(0, naturalWidth - containerWidth);
      const hiddenRatio = Math.min(1, Math.max(0, overflowPx / Math.max(containerWidth, 1)));
      let tailStart = chars.length;
      let lastVisibleIndex = chars.length - 1;
      if (isOverflowing) {
        const avgCharWidth = naturalWidth / chars.length;
        const visibleEstimate = Math.max(1, Math.floor(containerWidth / Math.max(avgCharWidth, 0.01)));
        lastVisibleIndex = Math.min(chars.length - 1, visibleEstimate - 1);
        const tailCount = Math.min(maxTailChars, chars.length);
        tailStart = Math.max(0, lastVisibleIndex - tailCount + 1);
      }
      setMetrics({ isOverflowing, overflowPx, hiddenRatio, tailStart, lastVisibleIndex });
    };

    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(container);
    let cancelled = false;
    document.fonts?.ready
      ?.then(() => {
        if (!cancelled) measure();
      })
      .catch(() => {});
    return () => {
      cancelled = true;
      ro.disconnect();
    };
  }, [chars, maxTailChars]);

  // Decompress choreography: spring open immediately, then — only if the
  // line still doesn't fit even at full width — glide left to reveal the
  // tail, hold, and glide back while focus/hover is still held.
  useEffect(() => {
    if (!active || !metrics.isOverflowing) {
      setPeeking(false);
      return;
    }
    if (reducedMotion) {
      setPeeking(true);
      return;
    }
    const openTimer = window.setTimeout(() => setPeeking(true), SPRING_MS);
    const closeTimer = window.setTimeout(() => setPeeking(false), SPRING_MS + PEEK_HOLD_MS);
    return () => {
      window.clearTimeout(openTimer);
      window.clearTimeout(closeTimer);
    };
  }, [active, metrics.isOverflowing, reducedMotion]);

  const { isOverflowing, overflowPx, hiddenRatio, tailStart, lastVisibleIndex } = metrics;
  const headStr = chars.slice(0, tailStart).join("");
  const tailChars = chars.slice(tailStart);
  const minLetterSpacing = -0.02 - hiddenRatio * 0.16; // -0.02em .. -0.18em
  const minStretch = 100 - hiddenRatio * 38; // 100% .. 62%
  const minScale = 1 - hiddenRatio * 0.16; // 1 .. 0.84
  const denom = Math.max(1, lastVisibleIndex - tailStart);

  const maskImage = isOverflowing
    ? "linear-gradient(to right, black, black calc(100% - 3ch), transparent 100%)"
    : undefined;

  return (
    <span
      ref={containerRef}
      tabIndex={0}
      title={text}
      data-vt-active={active ? "true" : undefined}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onFocus={() => setFocused(true)}
      onBlur={() => setFocused(false)}
      className={`ns-vt relative block cursor-default overflow-hidden whitespace-nowrap outline-none ${className}`}
      style={{
        maskImage,
        WebkitMaskImage: maskImage,
        transition: "-webkit-mask-image 260ms ease, mask-image 260ms ease",
      }}
    >
      <style>{`
        /* An outline paints outside the border box, but this element clips
           its own overflow — an outline here would itself get clipped away.
           An inset box-shadow paints inside instead, so it always shows. */
        .ns-vt:focus-visible {
          box-shadow: inset 0 0 0 2px var(--accent);
        }
        .ns-vt[data-vt-active="true"] {
          -webkit-mask-image: none !important;
          mask-image: none !important;
        }
        .ns-vt-tail-char {
          display: inline-block;
          transition:
            letter-spacing 220ms cubic-bezier(.16,1,.3,1),
            font-stretch 220ms cubic-bezier(.16,1,.3,1),
            transform 220ms cubic-bezier(.16,1,.3,1);
        }
        .ns-vt-line {
          display: inline-block;
          transition: transform ${PEEK_TRAVEL_MS}ms cubic-bezier(.65,0,.35,1);
        }
        [data-vt-active="true"] .ns-vt-tail-char {
          letter-spacing: 0 !important;
          font-stretch: 100% !important;
          transform: scaleX(1) !important;
        }
        @media (prefers-reduced-motion: reduce) {
          .ns-vt-tail-char, .ns-vt-line, .ns-vt {
            transition: none !important;
          }
        }
      `}</style>

      {/* Hidden measurer: same text, no taper styling — gives the natural,
          untapered width used to detect overflow and estimate the visible
          boundary. Visually inert, never read by AT (real text lives below). */}
      <span
        ref={measurerRef}
        aria-hidden
        className="pointer-events-none absolute left-0 top-0 whitespace-nowrap"
        style={{ visibility: "hidden" }}
      >
        {text}
      </span>

      <span
        className="ns-vt-line"
        style={{ transform: `translateX(${peeking ? -overflowPx : 0}px)` }}
      >
        {headStr}
        {tailChars.map((ch, j) => {
          const idx = tailStart + j;
          const t = Math.min(1, (idx - tailStart) / denom);
          const eased = Math.pow(t, 1.6);
          const letterSpacing = lerp(-0.004, minLetterSpacing, eased);
          const stretch = lerp(100, minStretch, eased);
          const scale = lerp(1, minScale, eased);
          return (
            <span
              key={idx}
              className="ns-vt-tail-char"
              style={{
                letterSpacing: `${letterSpacing.toFixed(3)}em`,
                fontStretch: `${stretch.toFixed(1)}%`,
                transform: `scaleX(${scale.toFixed(3)})`,
                transformOrigin: "right",
              }}
            >
              {ch}
            </span>
          );
        })}
      </span>
    </span>
  );
}
Use when

a table cell, breadcrumb, or file path needs to communicate 'there is more, and roughly how much' without spending three characters on '...' — the trailing run visibly crowds and fades toward the clip edge at rest, and hover/focus springs it back to full width (gliding the line left to peek the tail if it still doesn't fit). Distinct from streaming-markdown-caret (a streaming-markdown caret/settle effect, not a truncation strategy) and from hero-gravity-well (text-as-spectacle collapse, not a boring information-preserving line-clamp).

Build spec

A drop-in replacement for ellipsis truncation in table cells, breadcrumbs, and file paths: instead of hard-clipping overflow with '...', the trailing run of characters nearest the clip edge visibly crowds together and dissolves, so a still frame communicates both 'there is more' and roughly how much before hovering or focusing to read it. MECHANISM: the only required prop is `text` (the full string, rendered as real, complete DOM text — never aria-hidden decoration standing in for a label). On mount, and on every container resize, a hidden absolutely-positioned measurer clone (same text, no taper styling) is compared against the visible container's clientWidth to detect overflow and its magnitude in pixels. If the line overflows, the estimated visible boundary (natural width divided by character count, projected against the container width) locates roughly where the clip edge falls, and the last min(maxTailChars, length) characters counting back from that estimated edge — not the literal last characters of a string many times longer than the box — receive per-span negative letter-spacing (the property that actually reclaims layout width, stepping from ~0 down to as much as -0.18em), a matching horizontal scaleX squeeze (down to as low as 0.84, transform-origin: right, reinforcing the narrowing by eye without affecting layout) and font-stretch (100% down to ~62%, included for forward-compatibility with a variable font that ships a real wdth axis — Geist Sans, as shipped in this registry, has only a wght axis per its fvar table, so font-stretch is presently a harmless no-op and letter-spacing/scaleX carry the actual visual effect). All three ease in via t^1.6 across the tapering run, so most characters barely compress and only the last few crush hard. How aggressively they compress is proportional to `hiddenRatio` (overflow px over container width): a barely-clipped cell tapers subtly, a badly-clipped one visibly crowds at its edge, so the resting frame alone communicates overflow magnitude. A mask-image linear-gradient (alpha fade, not a background-colored overlay, so it's correct against any surface color without sampling one) fades the final 3ch of the container to transparent, dissolving the very edge instead of hard-cutting it. DECOMPRESS: focus or hover immediately springs every tapered span back to letter-spacing 0 / font-stretch 100% / scaleX 1 (a 220ms ease-out-expo transition) and removes the mask-image, so the visible run reads normally. If the line still doesn't fit even fully expanded (the same overflow-px value measured at rest — decompression can't change how much text there is), after that spring settles the whole line glides left on an ease-in-out 650ms transition to reveal the tail, holds ~900ms, and glides back — repeating for as long as hover/focus is held, resetting the instant it isn't. ACCESSIBILITY: the element is a tab stop and carries `title` with the full string; because the visible characters are real text nodes (mask and width-axis styling are paint-only, not display/visibility changes), a screen reader already reads the complete string regardless of visual state — `title`/tab-stop status exist for sighted mouse and keyboard users to trigger the same decompress a hover gives, not to compensate for a11y-hidden content. The admitted cost: a keyboard user must Tab to each cell individually to check for a hidden tail, the same cost native ellipsis-plus-tooltip already has. Under prefers-reduced-motion every transition (taper spring, mask fade, peek glide) is disabled — decompression on focus/hover still applies instantly and fully, just without animation; legibility never depends on motion running. Zero runtime dependencies, DOM + CSS only, no canvas.

Tags
texttruncationtabletypographyaccessibilityvariable-fonthover