3D Text Marquee

A bold word scrolling across two faces folded into a V. Each face is clipped and offset so the line reads as one continuous strip across the seam, with the face turned away from the light slightly darker.

Tweak it

Code

Copy markdown includes the prompt, your current settings and a single HTML file that runs on its own.

/* 3D Text Marquee — a word scrolling across faces folded into a V, inspired by
 * Comehope's "3d text marquee" CodePen.
 *
 * mount(root, settings) renders into `root` and returns { onChange(key) }.
 * `settings` is a plain object (start from DEFAULTS); change a value, then call
 * onChange(key) to apply it live. CONTROLS describes the tweakable values for
 * the playground's control panel.
 */

/* SETTINGS — every tweakable value lives here. */
export const DEFAULTS = {
  // Text
  text: 'Holy molly',
  separator: '  ',         // inserted between repeats ("  ", " • ", " — ")
  textColor: '#a100ff',
  bgColor: '#ffff00',
  fontSize: 120,           // px
  fontWeight: 700,
  letterSpacing: -2,       // px
  uppercase: false,

  // Faces (the folded 3D panels)
  panels: 2,               // number of faces the strip folds across
  panelWidth: 380,         // px per face
  panelHeight: 170,        // px
  panelColor: '#ffff00',
  showPanelBg: false,      // fill faces with panelColor (off = transparent)
  borderWidth: 0,          // px outline around each face
  borderColor: '#a100ff',

  // 3D
  angle: 22,               // fold angle of each face, degrees
  perspective: 420,        // px — smaller = more dramatic 3D
  tiltX: 0,                // tip the whole strip forwards/backwards
  shade: 0.18,             // darkens the text on the face turned away from the light

  // Motion
  speed: 140,              // px per second
  direction: 'left',       // 'left' | 'right'
  pauseOnHover: true,
  easing: 0.08,            // how quickly speed changes ease in/out (pause, direction)
  reducedMotion: false,    // the host turns this on when the visitor prefers reduced motion
};

/* Control panel schema */
export const CONTROLS = [
  { group: 'Text', items: [
    { key: 'text', type: 'text', label: 'Text' },
    { key: 'textColor', type: 'color', label: 'Text colour' },
    { key: 'bgColor', type: 'color', label: 'Background' },
  ]},
  { group: '3D fold', items: [
    { key: 'angle', type: 'range', label: 'Fold angle', min: 0, max: 70, step: 1, unit: '°' },
    { key: 'perspective', type: 'range', label: 'Perspective', min: 80, max: 2000, step: 10, unit: 'px' },
    { key: 'shade', type: 'range', label: 'Shading', min: 0, max: 0.6, step: 0.01 },
    { key: 'panels', type: 'range', label: 'Faces', min: 1, max: 6, step: 1 },
  ]},
  { group: 'Motion', items: [
    { key: 'speed', type: 'range', label: 'Speed', min: 0, max: 800, step: 5, unit: 'px/s' },
    { key: 'direction', type: 'select', label: 'Direction', options: [['left', 'Left'], ['right', 'Right']] },
    { key: 'pauseOnHover', type: 'toggle', label: 'Pause on hover' },
  ]},
];

export function mount(root, S) {
  root.classList.add('mq-root');
  root.innerHTML = '<div class="mq-fit"><div class="mq-box" role="marquee"></div></div>';
  const fit = root.querySelector('.mq-fit');
  const box = root.querySelector('.mq-box');

  let panels = [];       // { el, track, dark }
  let unitW = 1;         // width of one "text + separator" repeat
  let offset = 0;        // scroll position, px
  let vel = 0;           // current eased velocity
  let hovered = false, last = 0, raf = 0;

  const STRUCTURAL = new Set(['text', 'separator', 'fontSize', 'fontWeight', 'letterSpacing', 'uppercase', 'panels', 'panelWidth', 'panelHeight']);

  // Non-breaking spaces so the separator never collapses
  const unitText = () => ((S.text || ' ') + S.separator).replace(/ /g, ' ');

  function styleUnit(span) {
    span.style.fontSize = S.fontSize + 'px';
    span.style.fontWeight = S.fontWeight;
    span.style.letterSpacing = S.letterSpacing + 'px';
    span.style.textTransform = S.uppercase ? 'uppercase' : 'none';
    span.style.color = S.textColor;
  }

  /* Width of one "text + separator" repeat, i.e. the exact distance the strip
     can wrap by without a visible jump. Measured as the gap between two laid-out
     repeats with the fit-to-size scale removed, so it is in the same unscaled
     px as the track's translate (a scaled or single-span measure drifts and
     shows as a skip each loop). */
  function measureUnit() {
    const prev = fit.style.transform;
    fit.style.transform = 'none';
    const probe = document.createElement('div');
    probe.className = 'mq-track';
    probe.style.visibility = 'hidden';
    for (let k = 0; k < 2; k++) {
      const u = document.createElement('span');
      u.className = 'mq-unit';
      u.textContent = unitText();
      styleUnit(u);
      probe.appendChild(u);
    }
    box.appendChild(probe);
    const w = probe.children[1].getBoundingClientRect().left - probe.children[0].getBoundingClientRect().left;
    probe.remove();
    fit.style.transform = prev;
    return Math.max(1, w);
  }

  function build() {
    box.innerHTML = '';
    box.setAttribute('aria-label', S.text);

    unitW = measureUnit();

    const total = S.panels * S.panelWidth;
    const copies = Math.ceil((total + unitW) / unitW) + 1;

    panels = [];
    for (let i = 0; i < S.panels; i++) {
      const el = document.createElement('div');
      el.className = 'mq-panel';
      el.style.width = S.panelWidth + 'px';
      el.style.height = S.panelHeight + 'px';
      const track = document.createElement('div');
      track.className = 'mq-track';
      for (let c = 0; c < copies; c++) {
        const u = document.createElement('span');
        u.className = 'mq-unit';
        u.textContent = unitText();
        if (c > 0 || i > 0) u.setAttribute('aria-hidden', 'true');
        styleUnit(u);
        track.appendChild(u);
      }
      el.appendChild(track);
      box.appendChild(el);
      panels.push({ el, track, dark: 0 });
    }
    offset = offset % unitW;
    paintFold();
    paintLook();
    resize();
    render();
  }

  // Colours and outlines that don't need a rebuild
  function paintLook() {
    root.style.setProperty('--bg', S.bgColor);
    panels.forEach((p) => {
      p.el.style.background = S.showPanelBg ? S.panelColor : 'transparent';
      p.el.style.outline = S.borderWidth ? `${S.borderWidth}px solid ${S.borderColor}` : 'none';
      p.el.style.outlineOffset = `-${S.borderWidth}px`;
    });
  }

  /* The fold: faces alternate rotateY(-a) / rotateY(+a), hinged on the seam they
     share, so the strip reads as one ribbon bent into a V (or a zig-zag). */
  function paintFold() {
    const n = panels.length;
    panels.forEach((p, i) => {
      let sign, origin;
      if (n === 1) { sign = -1; origin = 'center'; }
      else if (i % 2 === 0) { sign = -1; origin = 'right center'; }
      else { sign = 1; origin = 'left center'; }
      p.el.style.transformOrigin = origin;
      p.el.style.transform = `perspective(${S.perspective}px) rotateX(${S.tiltX}deg) rotateY(${sign * S.angle}deg)`;
      // the face turned away catches less light — shade the letters, never the face
      p.dark = sign > 0 ? S.shade : S.shade * 0.25;
    });
    paintText();
  }

  function shadeColor(hex, amt) {
    const h = hex.replace('#', '');
    const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h.slice(0, 6), 16);
    const f = (c) => Math.round(c * (1 - amt));
    return `rgb(${f((n >> 16) & 255)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`;
  }
  function paintText() {
    panels.forEach((p) => {
      const col = shadeColor(S.textColor, p.dark || 0);
      p.track.querySelectorAll('.mq-unit').forEach((u) => (u.style.color = col));
    });
  }

  // Scale the whole strip to fit the root in both directions, so it works in a
  // small card as well as a full page.
  function resize() {
    const total = S.panels * S.panelWidth;
    const s = Math.min(1, (root.clientWidth - 48) / total, (root.clientHeight - 48) / (S.panelHeight * 1.4));
    fit.style.transform = `translate(-50%, -50%) scale(${Math.max(0.1, s)})`;
  }

  function render() {
    // Face i shows the slice [i*W, (i+1)*W] of one long continuous strip
    for (let i = 0; i < panels.length; i++) {
      panels[i].track.style.transform = `translate3d(${-(i * S.panelWidth) - offset}px,0,0)`;
    }
  }

  function targetVel() {
    if (S.reducedMotion) return 0;
    if (S.pauseOnHover && hovered) return 0;
    return S.speed * (S.direction === 'left' ? 1 : -1);
  }

  function tick(now) {
    const dt = Math.min(0.05, (now - last) / 1000 || 0);
    last = now;
    const tv = targetVel();
    if (S.reducedMotion) vel = 0;
    else vel += (tv - vel) * (1 - Math.pow(1 - S.easing, dt * 60));
    if (Math.abs(vel) < 0.01 && tv === 0) vel = 0;
    offset += vel * dt;
    offset = ((offset % unitW) + unitW) % unitW;
    render();
    raf = requestAnimationFrame(tick);
  }

  box.addEventListener('pointerenter', () => (hovered = true));
  box.addEventListener('pointerleave', () => (hovered = false));
  if (window.ResizeObserver) new ResizeObserver(resize).observe(root);
  else window.addEventListener('resize', resize);

  function onChange(key) {
    if (STRUCTURAL.has(key)) { clearTimeout(onChange.t); onChange.t = setTimeout(build, key === 'text' ? 120 : 30); return; }
    if (['angle', 'perspective', 'tiltX', 'shade'].includes(key)) paintFold();
    else { paintLook(); paintText(); }
  }

  // If a font finishes loading after the first build (slow network, swap), the
  // repeat width changes: rebuild so the loop stays seamless.
  const remeasure = () => { if (panels.length && Math.abs(measureUnit() - unitW) > 0.01) build(); };
  if (document.fonts) {
    document.fonts.ready.then(remeasure);
    if (document.fonts.addEventListener) document.fonts.addEventListener('loadingdone', remeasure);
  }

  const start = () => { build(); last = performance.now(); cancelAnimationFrame(raf); raf = requestAnimationFrame(tick); };
  if (document.fonts && document.fonts.load) {
    Promise.race([document.fonts.load(`${S.fontWeight} ${S.fontSize}px "Inter"`), new Promise((r) => setTimeout(r, 2500))])
      .catch(() => {}).then(start);
  } else start();

  return { onChange };
}