Web InnoventixFreeCode

3D Text Effect

Original · free

layered 3D text

byWeb InnoventixReact + Tailwind
txfx3dtext-effects
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/txfx-3d.json
txfx-3d.tsx
"use client";

import {
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { useReducedMotion } from "motion/react";

type RGB = readonly [number, number, number];

interface Theme {
  key: string;
  name: string;
  face: RGB;
  mid: RGB;
  deep: RGB;
}

interface Dir {
  key: string;
  label: string;
  x: number;
  y: number;
}

const THEMES: readonly Theme[] = [
  { key: "indigo", name: "Indigo", face: [165, 180, 252], mid: [79, 70, 229], deep: [30, 27, 75] },
  { key: "violet", name: "Violet", face: [196, 181, 253], mid: [124, 58, 237], deep: [46, 16, 101] },
  { key: "emerald", name: "Emerald", face: [110, 231, 183], mid: [16, 185, 129], deep: [6, 60, 46] },
  { key: "rose", name: "Rose", face: [253, 164, 175], mid: [225, 29, 72], deep: [76, 5, 25] },
  { key: "amber", name: "Amber", face: [253, 224, 135], mid: [217, 119, 6], deep: [69, 26, 3] },
  { key: "sky", name: "Sky", face: [125, 211, 252], mid: [2, 132, 199], deep: [8, 47, 73] },
];

const DIRS: readonly Dir[] = [
  { key: "nw", label: "Extrude up and left", x: -1, y: -1 },
  { key: "n", label: "Extrude up", x: 0, y: -1 },
  { key: "ne", label: "Extrude up and right", x: 1, y: -1 },
  { key: "w", label: "Extrude left", x: -1, y: 0 },
  { key: "e", label: "Extrude right", x: 1, y: 0 },
  { key: "sw", label: "Extrude down and left", x: -1, y: 1 },
  { key: "s", label: "Extrude down", x: 0, y: 1 },
  { key: "se", label: "Extrude down and right", x: 1, y: 1 },
];

const GRID_ORDER = ["nw", "n", "ne", "w", "center", "e", "sw", "s", "se"] as const;

const STEP = 2.2;
const MAX_DEPTH = 24;

const rgbStr = (c: RGB): string => `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
const rgbaStr = (c: RGB, a: number): string => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`;
const lerp = (a: RGB, b: RGB, t: number): RGB => [
  Math.round(a[0] + (b[0] - a[0]) * t),
  Math.round(a[1] + (b[1] - a[1]) * t),
  Math.round(a[2] + (b[2] - a[2]) * t),
];

const DEFAULTS = {
  text: "LAYERS",
  depth: 14,
  dirKey: "se",
  themeKey: "indigo",
  animate: true,
};

export default function Txfx3D() {
  const uid = useId();
  const reduced = useReducedMotion();

  const [text, setText] = useState<string>(DEFAULTS.text);
  const [depth, setDepth] = useState<number>(DEFAULTS.depth);
  const [dirKey, setDirKey] = useState<string>(DEFAULTS.dirKey);
  const [themeKey, setThemeKey] = useState<string>(DEFAULTS.themeKey);
  const [animate, setAnimate] = useState<boolean>(DEFAULTS.animate);
  const [tilt, setTilt] = useState<{ rx: number; ry: number }>({ rx: 0, ry: 0 });

  const stageRef = useRef<HTMLDivElement>(null);

  const theme = useMemo(
    () => THEMES.find((t) => t.key === themeKey) ?? THEMES[0],
    [themeKey],
  );
  const dir = useMemo(() => DIRS.find((d) => d.key === dirKey) ?? DIRS[7], [dirKey]);

  const unit = useMemo(() => {
    const mag = Math.hypot(dir.x, dir.y) || 1;
    return { ux: dir.x / mag, uy: dir.y / mag };
  }, [dir]);

  const layers = useMemo(() => {
    const arr: number[] = [];
    for (let i = depth; i >= 1; i--) arr.push(i);
    return arr;
  }, [depth]);

  const motionOn = animate && !reduced;
  const displayText = text.length === 0 ? " " : text;

  const faceStyle: CSSProperties = {
    backgroundImage: `linear-gradient(100deg, ${rgbStr(theme.mid)} 0%, ${rgbStr(
      theme.face,
    )} 40%, rgba(255,255,255,0.9) 50%, ${rgbStr(theme.face)} 60%, ${rgbStr(theme.mid)} 100%)`,
    backgroundSize: "220% 100%",
    WebkitBackgroundClip: "text",
    backgroundClip: "text",
    color: "transparent",
    WebkitTextFillColor: "transparent",
    animation: motionOn ? "txfx3d-shimmer 6s linear infinite" : undefined,
  };

  const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (reduced) return;
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const px = (e.clientX - rect.left) / rect.width - 0.5;
    const py = (e.clientY - rect.top) / rect.height - 0.5;
    setTilt({ rx: -py * 14, ry: px * 16 });
  };

  const resetTilt = () => setTilt({ rx: 0, ry: 0 });

  const reset = () => {
    setText(DEFAULTS.text);
    setDepth(DEFAULTS.depth);
    setDirKey(DEFAULTS.dirKey);
    setThemeKey(DEFAULTS.themeKey);
    setAnimate(DEFAULTS.animate);
    resetTilt();
  };

  const arrowAngle = Math.round((Math.atan2(dir.y, dir.x) * 180) / Math.PI);

  const dirName = uid + "-dir";
  const themeName = uid + "-theme";
  const textId = uid + "-text";
  const depthId = uid + "-depth";

  const ringBase =
    "outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950";

  return (
    <section className="relative w-full overflow-hidden bg-white px-6 py-16 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100 sm:py-20">
      <style>{`
        @keyframes txfx3d-shimmer {
          0% { background-position: 0% 50%; }
          100% { background-position: 220% 50%; }
        }
        @keyframes txfx3d-float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-10px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .txfx3d-face { animation: none !important; }
          .txfx3d-float { animation: none !important; }
        }
      `}</style>

      <div className="pointer-events-none absolute inset-x-0 top-0 mx-auto h-px max-w-6xl bg-gradient-to-r from-transparent via-neutral-300 to-transparent dark:via-neutral-700" />

      <div className="mx-auto max-w-6xl">
        <header className="mb-10 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-neutral-50 px-3 py-1 text-xs font-medium uppercase tracking-widest text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
            <span
              aria-hidden="true"
              className="h-1.5 w-1.5 rounded-full"
              style={{ backgroundColor: rgbStr(theme.mid) }}
            />
            Text effect
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Layered 3D Text
          </h2>
          <p className="mt-3 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
            Real extruded depth built from stacked glyph layers — no images, no WebGL. Type your
            word, drag the depth, and steer the light. Hover the stage to tilt it in space.
          </p>
        </header>

        <div className="grid gap-6 lg:grid-cols-[1.5fr_1fr]">
          {/* ---------- Stage ---------- */}
          <div
            ref={stageRef}
            onPointerMove={handlePointerMove}
            onPointerLeave={resetTilt}
            className="relative flex min-h-[20rem] items-center justify-center overflow-hidden rounded-3xl border border-neutral-200 bg-gradient-to-br from-neutral-50 to-neutral-100 p-8 dark:border-neutral-800 dark:from-neutral-900 dark:to-neutral-950 sm:min-h-[24rem]"
            style={{ perspective: "900px" }}
          >
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-0 opacity-[0.35] dark:opacity-20"
              style={{
                backgroundImage:
                  "radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)",
                backgroundSize: "22px 22px",
                color: "rgb(148 163 184)",
                maskImage: "radial-gradient(ellipse at center, black 40%, transparent 80%)",
                WebkitMaskImage: "radial-gradient(ellipse at center, black 40%, transparent 80%)",
              }}
            />

            <div
              className="relative"
              style={{
                transform: `rotateX(${tilt.rx}deg) rotateY(${tilt.ry}deg)`,
                transformStyle: "preserve-3d",
                transition: "transform 160ms ease-out",
              }}
            >
              <div
                className={motionOn ? "txfx3d-float" : undefined}
                style={{
                  animation: motionOn ? "txfx3d-float 5s ease-in-out infinite" : undefined,
                  filter: `drop-shadow(0 18px 30px ${rgbaStr(theme.mid, 0.3)})`,
                }}
              >
                <div
                  className="relative inline-block select-none whitespace-nowrap font-black leading-none tracking-tight"
                  style={{ fontSize: "clamp(3rem, 13vw, 8rem)" }}
                >
                  {layers.map((i) => {
                    const t = depth > 1 ? (i - 1) / (depth - 1) : 0;
                    const color = lerp(theme.mid, theme.deep, t);
                    return (
                      <span
                        key={i}
                        aria-hidden="true"
                        className="absolute inset-0 block"
                        style={{
                          transform: `translate(${unit.ux * i * STEP}px, ${unit.uy * i * STEP}px)`,
                          color: rgbStr(color),
                        }}
                      >
                        {displayText}
                      </span>
                    );
                  })}
                  <span className="txfx3d-face relative block" style={faceStyle}>
                    {displayText}
                  </span>
                </div>
              </div>
            </div>
          </div>

          {/* ---------- Controls ---------- */}
          <div className="space-y-6 rounded-3xl border border-neutral-200 bg-neutral-50 p-6 dark:border-neutral-800 dark:bg-neutral-900">
            {/* Text */}
            <div>
              <label
                htmlFor={textId}
                className="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300"
              >
                Display text
              </label>
              <input
                id={textId}
                type="text"
                value={text}
                maxLength={14}
                onChange={(e) => setText(e.target.value.toUpperCase())}
                placeholder="Type a word"
                className={`w-full rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-sm font-semibold uppercase tracking-wide text-neutral-900 placeholder:font-normal placeholder:normal-case placeholder:tracking-normal placeholder:text-neutral-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100 ${ringBase}`}
              />
            </div>

            {/* Depth */}
            <div>
              <div className="mb-2 flex items-baseline justify-between">
                <label
                  htmlFor={depthId}
                  className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
                >
                  Extrusion depth
                </label>
                <span className="text-xs font-semibold tabular-nums text-neutral-500 dark:text-neutral-400">
                  {depth} {depth === 1 ? "layer" : "layers"}
                </span>
              </div>
              <input
                id={depthId}
                type="range"
                min={0}
                max={MAX_DEPTH}
                step={1}
                value={depth}
                onChange={(e) => setDepth(Number(e.target.value))}
                aria-valuetext={`${depth} layers of depth`}
                className={`w-full cursor-pointer ${ringBase} rounded-full`}
                style={{ accentColor: rgbStr(theme.mid) }}
              />
            </div>

            {/* Direction */}
            <fieldset>
              <legend className="mb-2 text-sm font-medium text-neutral-700 dark:text-neutral-300">
                Extrusion direction
              </legend>
              <div className="grid w-max grid-cols-3 gap-1.5">
                {GRID_ORDER.map((key) => {
                  if (key === "center") {
                    return (
                      <div
                        key="center"
                        aria-hidden="true"
                        className="flex h-11 w-11 items-center justify-center rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700"
                      >
                        <span className="text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
                          Face
                        </span>
                      </div>
                    );
                  }
                  const d = DIRS.find((item) => item.key === key)!;
                  const active = dirKey === key;
                  const angle = Math.round((Math.atan2(d.y, d.x) * 180) / Math.PI);
                  return (
                    <label
                      key={key}
                      className={`relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-indigo-500 has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-neutral-50 dark:has-[:focus-visible]:ring-offset-neutral-900 ${
                        active
                          ? "border-transparent text-white"
                          : "border-neutral-300 bg-white text-neutral-500 hover:border-neutral-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-400 dark:hover:border-neutral-600"
                      }`}
                      style={active ? { backgroundColor: rgbStr(theme.mid) } : undefined}
                    >
                      <input
                        type="radio"
                        name={dirName}
                        value={key}
                        checked={active}
                        onChange={() => setDirKey(key)}
                        className="sr-only"
                      />
                      <svg
                        viewBox="0 0 24 24"
                        className="h-4 w-4"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={2.5}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                        style={{ transform: `rotate(${angle}deg)` }}
                      >
                        <path d="M5 12h13M13 6l6 6-6 6" />
                      </svg>
                      <span className="sr-only">{d.label}</span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            {/* Color */}
            <fieldset>
              <legend className="mb-2 text-sm font-medium text-neutral-700 dark:text-neutral-300">
                Color
              </legend>
              <div className="flex flex-wrap gap-2">
                {THEMES.map((t) => {
                  const active = themeKey === t.key;
                  return (
                    <label
                      key={t.key}
                      className={`relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg ring-offset-2 ring-offset-neutral-50 transition-transform hover:scale-105 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-indigo-500 dark:ring-offset-neutral-900 ${
                        active ? "ring-2 ring-neutral-900 dark:ring-white" : ""
                      }`}
                      title={t.name}
                    >
                      <input
                        type="radio"
                        name={themeName}
                        value={t.key}
                        checked={active}
                        onChange={() => setThemeKey(t.key)}
                        className="sr-only"
                      />
                      <span
                        aria-hidden="true"
                        className="h-full w-full rounded-lg"
                        style={{
                          backgroundImage: `linear-gradient(135deg, ${rgbStr(t.face)}, ${rgbStr(
                            t.mid,
                          )} 55%, ${rgbStr(t.deep)})`,
                        }}
                      />
                      {active ? (
                        <svg
                          viewBox="0 0 24 24"
                          className="absolute h-4 w-4 text-white drop-shadow"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={3}
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          aria-hidden="true"
                        >
                          <path d="M5 12l5 5L20 7" />
                        </svg>
                      ) : null}
                      <span className="sr-only">{t.name}</span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            {/* Animate toggle + reset */}
            <div className="flex items-center justify-between gap-4 border-t border-neutral-200 pt-5 dark:border-neutral-800">
              <span className="flex flex-col">
                <span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
                  Shimmer &amp; float
                </span>
                <span className="text-xs text-neutral-500 dark:text-neutral-500">
                  {reduced ? "Off (reduced motion)" : animate ? "Animating" : "Static"}
                </span>
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={animate && !reduced}
                aria-label="Toggle shimmer and float animation"
                disabled={!!reduced}
                onClick={() => setAnimate((v) => !v)}
                className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${ringBase} ${
                  animate && !reduced ? "" : "bg-neutral-300 dark:bg-neutral-700"
                }`}
                style={animate && !reduced ? { backgroundColor: rgbStr(theme.mid) } : undefined}
              >
                <span
                  aria-hidden="true"
                  className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
                    animate && !reduced ? "translate-x-6" : "translate-x-1"
                  }`}
                />
              </button>
            </div>

            <button
              type="button"
              onClick={reset}
              className={`inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 ${ringBase} rounded-md`}
            >
              <svg
                viewBox="0 0 24 24"
                className="h-4 w-4"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8" />
                <path d="M3 3v5h5" />
              </svg>
              Reset to defaults
            </button>
          </div>
        </div>

        <p className="mt-4 text-sm text-neutral-500 dark:text-neutral-500" aria-live="polite">
          {depth} stacked {depth === 1 ? "layer" : "layers"} extruded {dir.label
            .replace("Extrude ", "")
            .toLowerCase()} at {arrowAngle}&deg;, in {theme.name.toLowerCase()}.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →