Extrude
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes field-extrude-pop { from { transform: translateZ(-60px) scale(0.4); opacity: 0; } }
.field-extrude { width: 250px; }
.field-extrude__stage { display: grid; place-items: center; height: 92px; perspective: 500px; }
.field-extrude__word {
display: grid;
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 42px; font-weight: 600; line-height: 1;
letter-spacing: -0.02em;
white-space: pre;
transform-style: preserve-3d;
}
/* Depth and face are separate layers, so no letter's depth can paint over its neighbour's face. */
.field-extrude__word > span { grid-area: 1 / 1; }
.field-extrude__depth { color: transparent; }
.field-extrude__ch { display: inline-block; animation: field-extrude-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.field-extrude__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-extrude__input {
width: 100%;
height: 38px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 9px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 14px; font-weight: 600;
transition: box-shadow 0.2s;
}
.field-extrude__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-extrude__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.field-extrude__probe i:nth-child(1) { color: light-dark(#6d7179, #7d8087); }
.field-extrude__probe i:nth-child(2) { color: light-dark(#ebedf0, #232529); }
@media (prefers-reduced-motion: reduce) { .field-extrude__ch { animation: none; } }
`;
const DEPTH = 12;
const rgb = (c: string) => (c.match(/[\d.]+/g) ?? ['0', '0', '0']).slice(0, 3).map(Number);
export function ExtrudeInput() {
const root = useRef<HTMLDivElement>(null);
const word = useRef<HTMLDivElement>(null);
const depth = useRef<HTMLSpanElement>(null);
const probe = useRef<HTMLSpanElement>(null);
const ptr = useRef<{ x: number; y: number } | null>(null);
const [value, setValue] = useState('Depth');
useEffect(() => {
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const t = { x: 0, y: 0 };
let raf = 0;
const tick = (now: number) => {
// Tilt toward the pointer; with no pointer, sway slowly on its own.
const p = ptr.current;
const tx = p ? (p.x - 0.5) * 2 : still ? 0.3 : Math.sin(now / 1400) * 0.6;
const ty = p ? (p.y - 0.5) * 2 : still ? -0.3 : Math.cos(now / 1900) * 0.35;
t.x += (tx - t.x) * 0.12;
t.y += (ty - t.y) * 0.12;
const [near, far] = [...probe.current!.children].map((c) => rgb(getComputedStyle(c).color));
// The extrusion runs away from the viewer, so it always shows on the side you're looking from.
const shadows = Array.from({ length: DEPTH }, (_, i) => {
const k = (i + 1) / DEPTH;
const c = near.map((v, j) => Math.round(v + (far[j] - v) * k));
return `${(-t.x * (i + 1) * 0.9).toFixed(1)}px ${(-t.y * (i + 1) * 0.9 + (i + 1) * 0.4).toFixed(1)}px 0 rgb(${c.join(',')})`;
}).join(',');
const w = word.current!;
depth.current!.style.textShadow = shadows;
w.style.transform = `rotateY(${(t.x * 18).toFixed(2)}deg) rotateX(${(-t.y * 14).toFixed(2)}deg)`;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<div
ref={root}
className="field-extrude"
onPointerMove={(e) => {
const r = root.current!.getBoundingClientRect();
ptr.current = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
}}
onPointerLeave={() => (ptr.current = null)}
>
<span ref={probe} className="field-extrude__probe" aria-hidden="true">
<i />
<i />
</span>
<div className="field-extrude__stage" aria-hidden="true">
<div ref={word} className="field-extrude__word">
{[depth, null].map((ref, layer) => (
<span key={layer} ref={ref} className={layer ? undefined : 'field-extrude__depth'}>
{[...value].map((c, i) => (
<span key={`${i}${c}`} className="field-extrude__ch">
{c}
</span>
))}
</span>
))}
</div>
</div>
<label className="field-extrude__label" htmlFor="field-extrude-input">
Headline
</label>
<input
id="field-extrude-input"
className="field-extrude__input"
value={value}
maxLength={8}
autoComplete="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldExtrude(el) on the mounted element instead.
<div class="field-extrude">
<span class="field-extrude__probe" aria-hidden="true">
<i></i>
<i></i>
</span>
<div class="field-extrude__stage" aria-hidden="true">
<div class="field-extrude__word"><span class="field-extrude__depth"><span class="field-extrude__ch">D</span><span class="field-extrude__ch">e</span><span class="field-extrude__ch">p</span><span class="field-extrude__ch">t</span><span class="field-extrude__ch">h</span></span><span><span class="field-extrude__ch">D</span><span class="field-extrude__ch">e</span><span class="field-extrude__ch">p</span><span class="field-extrude__ch">t</span><span class="field-extrude__ch">h</span></span></div>
</div>
<label class="field-extrude__label" for="field-extrude-input">Headline</label>
<input id="field-extrude-input" class="field-extrude__input" maxLength="8" autoComplete="off" spellCheck="false" value="Depth"/>
</div>@keyframes field-extrude-pop { from { transform: translateZ(-60px) scale(0.4); opacity: 0; } }
.field-extrude { width: 250px; }
.field-extrude__stage { display: grid; place-items: center; height: 92px; perspective: 500px; }
.field-extrude__word {
display: grid;
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 42px; font-weight: 600; line-height: 1;
letter-spacing: -0.02em;
white-space: pre;
transform-style: preserve-3d;
}
/* Depth and face are separate layers, so no letter's depth can paint over its neighbour's face. */
.field-extrude__word > span { grid-area: 1 / 1; }
.field-extrude__depth { color: transparent; }
.field-extrude__ch { display: inline-block; animation: field-extrude-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.field-extrude__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-extrude__input {
width: 100%;
height: 38px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 9px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 14px; font-weight: 600;
transition: box-shadow 0.2s;
}
.field-extrude__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-extrude__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.field-extrude__probe i:nth-child(1) { color: light-dark(#6d7179, #7d8087); }
.field-extrude__probe i:nth-child(2) { color: light-dark(#ebedf0, #232529); }
@media (prefers-reduced-motion: reduce) { .field-extrude__ch { animation: none; } }function initFieldExtrude(root) {
var word = root.querySelector('.field-extrude__word');
var probe = root.querySelector('.field-extrude__probe');
var input = root.querySelector('.field-extrude__input');
if (!word || !probe || !input) return;
var layers = word.children;
var depth = layers[0];
var DEPTH = 12;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var ptr = null;
var t = { x: 0, y: 0 };
var raf = 0;
var value = input.value;
function rgb(c) { return (c.match(/[\d.]+/g) || ['0', '0', '0']).slice(0, 3).map(Number); }
function tick(now) {
// Tilt toward the pointer; with no pointer, sway slowly on its own.
var tx = ptr ? (ptr.x - 0.5) * 2 : still ? 0.3 : Math.sin(now / 1400) * 0.6;
var ty = ptr ? (ptr.y - 0.5) * 2 : still ? -0.3 : Math.cos(now / 1900) * 0.35;
t.x += (tx - t.x) * 0.12;
t.y += (ty - t.y) * 0.12;
var near = rgb(getComputedStyle(probe.children[0]).color);
var far = rgb(getComputedStyle(probe.children[1]).color);
// The extrusion runs away from the viewer, so it always shows on the side you're looking from.
var shadows = [];
for (var i = 0; i < DEPTH; i++) {
var k = (i + 1) / DEPTH;
var c = near.map(function (v, j) { return Math.round(v + (far[j] - v) * k); });
shadows.push((-t.x * (i + 1) * 0.9).toFixed(1) + 'px ' + (-t.y * (i + 1) * 0.9 + (i + 1) * 0.4).toFixed(1) + 'px 0 rgb(' + c.join(',') + ')');
}
depth.style.textShadow = shadows.join(',');
word.style.transform = 'rotateY(' + (t.x * 18).toFixed(2) + 'deg) rotateX(' + (-t.y * 14).toFixed(2) + 'deg)';
raf = requestAnimationFrame(tick);
}
// Letters keyed by position and character: only a changed one pops in, on both layers.
function change() {
var next = input.value;
Array.prototype.forEach.call(layers, function (layer) {
var spans = layer.children;
while (spans.length > next.length) layer.lastChild.remove();
next.split('').forEach(function (c, i) {
if (spans[i] && value[i] === c) return;
var s = document.createElement('span');
s.className = 'field-extrude__ch';
s.textContent = c;
if (spans[i]) spans[i].replaceWith(s);
else layer.appendChild(s);
});
});
value = next;
}
function move(e) {
var r = root.getBoundingClientRect();
ptr = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
}
function leave() { ptr = null; }
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', leave);
input.addEventListener('input', change);
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerleave', leave);
input.removeEventListener('input', change);
};
}
document.querySelectorAll('.field-extrude').forEach(initFieldExtrude);Goal: Create a "Extrude" input.
Visual behavior: A headline field that renders your text as solid 3D type above it: the extrusion turns to face the pointer, swaying gently on its own when you are elsewhere, and each new letter pops out of the depth.
Technique:
- The word is drawn twice in one grid cell: a depth layer with transparent text whose text-shadow stacks twelve 1px steps from a lighter to a darker palette tone, and the face on top. Separate layers mean no letter's depth paints over a neighbour.
- Each frame the tilt eases toward the pointer (or a slow sway); the shadow offsets run away from the viewer and the word rotates on X and Y inside a perspective.
- Letters are spans keyed by position and character, so only changed ones pop in.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The 3D word is aria-hidden. Under prefers-reduced-motion it holds a fixed tilt and letters do not pop.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Extrude" input.
Visual behavior: A headline field that renders your text as solid 3D type above it: the extrusion turns to face the pointer, swaying gently on its own when you are elsewhere, and each new letter pops out of the depth.
Technique:
- The word is drawn twice in one grid cell: a depth layer with transparent text whose text-shadow stacks twelve 1px steps from a lighter to a darker palette tone, and the face on top. Separate layers mean no letter's depth paints over a neighbour.
- Each frame the tilt eases toward the pointer (or a slow sway); the shadow offsets run away from the viewer and the word rotates on X and Y inside a perspective.
- Letters are spans keyed by position and character, so only changed ones pop in.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The 3D word is aria-hidden. Under prefers-reduced-motion it holds a fixed tilt and letters do not pop.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A headline field that renders your text as solid 3D type above it: the extrusion turns to face the pointer, swaying gently on its own when you are elsewhere, and each new letter pops out of the depth.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- mouse tracking, 3d, state change
- Techniques
- raf, mouse tracking, 3d transform, perspective
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The 3D word is aria-hidden. Under prefers-reduced-motion it holds a fixed tilt and letters do not pop.