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.
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 };
}/* ---------- Experiment root (mount anywhere) ---------- */
.mq-root {
--bg: #ffff00;
position: relative;
width: 100%;
height: 100%;
min-height: 100%;
background: var(--bg);
overflow: hidden;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
transition: background-color .3s;
}
.mq-fit { position: absolute; left: 50%; top: 50%; transform-origin: center center; will-change: transform; }
.mq-box { display: flex; align-items: center; }
.mq-panel {
position: relative;
overflow: hidden;
flex: none;
box-sizing: border-box;
backface-visibility: hidden;
}
.mq-track {
position: absolute; top: 0; left: 0; height: 100%;
display: flex; align-items: center;
white-space: nowrap;
will-change: transform;
}
.mq-unit { flex: none; line-height: 1; }
@media (prefers-reduced-motion: reduce) {
.mq-root { transition: none; }
}Build a single self-contained HTML file (vanilla JS, no libraries) with a 3D folded text marquee inspired by Comehope’s “3d text marquee” CodePen. Show a bold Inter word (“Holy molly”, purple #a100ff on yellow #ffff00) scrolling across two side-by-side clipped panels. Rotate the left panel with perspective() rotateY(-angle), hinged on its right edge, and the right panel with rotateY(+angle), hinged on its left edge, so they form a V fold. Each panel holds a repeated copy of the text, offset by the panel’s index × width, so the line reads as one continuous strip across the seam. Drive the scroll with requestAnimationFrame so it loops seamlessly, and add light shading on one face. Put every value in one settings object with a compact live control panel (sliders, colour pickers, toggles, copy/reset buttons). Keep it in one root element, scale it to fit small screens, and respect prefers-reduced-motion.