# Felt Letter Board Prompt Box Where Typed Plastic Letters Drop Out of the Grooves and a Homepage Is Set in Their Place

> Prompt box built as a felt letter board: each key pushes a plastic letter into the groovesand Enter sets a homepage. Free React and HTML code.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/felt-letter-board-prompt-box-where-typed-plastic-letters-drop-out-of-the-grooves-and-a-homepage-is-set-in-their-place).

- Page, with a live preview: https://shadcn.exchange/r/weinc/felt-letter-board-prompt-box-where-typed-plastic-letters-drop-out-of-the-grooves-and-a-homepage-is-set-in-their-place
- Screenshot: https://shadcn.exchange/shot/weinc/felt-letter-board-prompt-box-where-typed-plastic-letters-drop-out-of-the-grooves-and-a-homepage-is-set-in-their-place/light.png
- Registry item: https://we.inc/r/felt-letter-board-prompt-box-where-typed-plastic-letters-drop-out-of-the-grooves-and-a-homepage-is-set-in-their-place.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/felt-letter-board-prompt-box-where-typed-plastic-letters-drop-out-of-the-grooves-and-a-homepage-is-set-in-their-place.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/felt-letter-board-prompt-box-where-typed-plastic-letters-drop-out-of-the-grooves-and-a-homepage-is-set-in-their-place.tsx`

```tsx
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, SyntheticEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

type Page = { brand: string; nav: string[]; head: string[]; sub: string; facts: string[]; cta: string; src?: string; alt?: string; focus?: string };

// The sentences the board sets by itself, and the page each one turns into. Headline rows hold ten letters.
const pages: (Page & { sentence: string })[] = [
  { sentence: "A coffee roastery in Porto that posts fresh beans weekly", brand: "DRUM & DRIP", nav: ["BEANS", "VISIT", "POST"], head: ["FRESH", "ROAST BY", "FRIDAY"], sub: "SMALL BATCHES, POSTED WEEKLY", facts: ["OPEN 7 TO 4", "ROASTED HERE", "BEANS BY POST"], cta: "Order beans", focus: "62% 50%", src: "https://images.pexels.com/photos/31986827/pexels-photo-31986827.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "Hot water poured from a kettle over ground coffee in a glass dripper" },
  { sentence: "A bike workshop in Ghent with same day puncture repairs", brand: "SPOKE HOUSE", nav: ["REPAIRS", "HIRE", "VISIT"], head: ["BACK ON", "TWO WHEELS", "TODAY"], sub: "PUNCTURES MENDED WHILE YOU WAIT", facts: ["DROP IN BY TEN", "LOAN BIKES", "OPEN SIX DAYS"], cta: "Book a repair", focus: "50% 40%", src: "https://images.pexels.com/photos/31452612/pexels-photo-31452612.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "A bicycle hanging in a lit workshop seen through a window from the street" },
  { sentence: "A plant shop in Lyon that runs repotting evenings", brand: "FERN & CO", nav: ["PLANTS", "EVENINGS", "VISIT"], head: ["GREEN", "THINGS,", "WELL KEPT"], sub: "REPOTTING EVENINGS ON THURSDAYS", facts: ["BRING A POT", "SOIL SUPPLIED", "TEA AT SEVEN"], cta: "Save a seat", focus: "50% 45%", src: "https://images.pexels.com/photos/14515146/pexels-photo-14515146.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "A small plant shop crowded with potted and hanging plants on wooden shelves" },
];
const BLUE = "#4F98FB";
const HOLD = 4600; // how long the self-running demo leaves a finished page on the board
const MAX = 72; // most characters the board accepts
const COLS = 17, ROWS = 5; // the sentence is set in a grid of this many letters
const CELL = 21, PITCH = 39; // letter width and row spacing for the sentence, in pixels

const W = 560, H = 704; // the scene is drawn at this size and scaled to fit
const FELT_W = 396, FELT_H = 520, PAD = 20;
const SX = Math.round((FELT_W - COLS * CELL) / 2), SY = 132;
const FONT = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';
const svg = (w: number, h: number, body: string) => `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}'>${body}<rect width='${w}' height='${h}' filter='url(#f)'/></svg>`)}")`;
const FELT = svg(240, 240, "<filter id='f'><feTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' seed='3'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.55 -0.2'/></filter>");
const GRAIN_H = svg(640, 64, "<filter id='f' x='0' y='0' width='100%' height='100%'><feTurbulence type='fractalNoise' baseFrequency='0.005 0.5' numOctaves='3' seed='11'/><feColorMatrix values='0 0 0 0 0.32 0 0 0 0 0.19 0 0 0 0 0.07 0 0 0 1.3 -0.5'/></filter>");
const GRAIN_V = svg(64, 640, "<filter id='f' x='0' y='0' width='100%' height='100%'><feTurbulence type='fractalNoise' baseFrequency='0.5 0.005' numOctaves='3' seed='5'/><feColorMatrix values='0 0 0 0 0.32 0 0 0 0 0.19 0 0 0 0 0.07 0 0 0 1.3 -0.5'/></filter>");

// Every letter sits a little crooked, the same way each time.
const wobble = (n: number) => { const v = Math.sin(n * 127.1 + 31.7) * 43758.5453; return (v - Math.floor(v)) * 2 - 1; };
const WHITE = "0 -1px 0 #ffffff, 0 1.5px 0 #9c978b, 0 4px 4px rgba(0,0,0,0.9)";
const INKED = "0 -1px 0 #cfe3ff, 0 1.5px 0 #1f5fc4, 0 4px 4px rgba(0,0,0,0.9)";

// Break a sentence into rows of the grid without splitting words.
function setType(s: string, cols: number) {
  const at: { r: number; c: number }[] = [];
  let r = 0, c = 0;
  for (let i = 0; i < s.length; i++) {
    if (s[i] === " ") { at.push({ r, c }); c += 1; continue; }
    if (i === 0 || s[i - 1] === " ") {
      let len = 0;
      while (i + len < s.length && s[i + len] !== " ") len += 1;
      if (c > 0 && c + len > cols) { r += 1; c = 0; }
    }
    if (c >= cols) { r += 1; c = 0; }
    at.push({ r, c }); c += 1;
  }
  const end = c >= cols ? { r: r + 1, c: 0 } : { r, c };
  return { at, end, rows: r + 1 };
}

const pageFrom = (words: string): Page => {
  const clean = words.trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "").toUpperCase();
  const set = setType(clean, 10);
  const head: string[] = [];
  set.at.forEach((p, i) => { if (p.r < 3 && p.c < 10) head[p.r] = (head[p.r] ?? "") + clean[i]; });
  // Do not end the headline on a joining word.
  const rows = head.map((h) => h.trim()).filter(Boolean);
  if (set.rows > 3 && rows.length) rows[rows.length - 1] = rows[rows.length - 1].replace(/\s+(WITH|IN|THAT|FOR|AND|A|AN|THE|OF|WHERE|TO|ON|AT)$/, "") || rows[rows.length - 1];
  return { brand: "YOUR PAGE", nav: ["ABOUT", "WORK", "CONTACT"], head: rows, sub: "SET FROM YOUR OWN SENTENCE", facts: ["WORDS BY YOU", "LAYOUT BY US", "YOURS TO EDIT"], cta: "Get in touch" };
};

type Fall = { d: number; j: number };
const drop = { hide: { opacity: 0, scale: 1.7 }, show: (f: Fall) => ({ opacity: 1, scale: 1, y: 0, rotate: f.j * 2.4, transition: { delay: f.d, type: "spring" as const, stiffness: 520, damping: 24 } }), gone: (f: Fall) => ({ y: FELT_H + 40, rotate: f.j * 70, transition: { duration: 0.5 + Math.abs(f.j) * 0.2, delay: Math.abs(wobble(f.j * 9)) * 0.16, ease: [0.55, 0, 1, 0.55] as [number, number, number, number] } }) };

// One row of plastic letters pushed into the grooves.
function Line({ text, x, y, cell, size, row, blue, dim, right }: { text: string; x?: number; y: number; cell: number; size: number; row: number; blue?: boolean; dim?: boolean; right?: boolean }) {
  const left = right ? FELT_W - PAD - text.length * cell : x ?? PAD;
  return (
    <>
      {text.split("").map((ch, i) => ch === " " ? null : (
        <motion.span key={`${y}-${i}`} variants={drop} custom={{ d: 0.34 + row * 0.085 + i * 0.016, j: wobble(y * 3 + i) }} className="absolute block text-center font-extrabold" style={{ left: left + i * cell, top: y + wobble(y + i * 7) * 0.9, width: cell, fontSize: size, lineHeight: 1, color: blue ? BLUE : dim ? "#bdb9ae" : "#F6F4EF", textShadow: blue ? INKED : WHITE }}>{ch}</motion.span>
      ))}
    </>
  );
}

export default function FeltLetterBoardPrompt() {
  const reduce = !!useReducedMotion();
  const root = useRef<HTMLDivElement>(null);
  const field = useRef<HTMLTextAreaElement>(null);
  const [k, setK] = useState(1);
  const [text, setText] = useState(() => pages[0].sentence.slice(0, pages[0].sentence.length - 3));
  const [caret, setCaret] = useState(-1); // -1 follows the end of the text
  const [page, setPage] = useState<Page | null>(null);
  const [focus, setFocus] = useState(false);
  const [said, setSaid] = useState("");
  const mine = useRef(false); // a visitor has started typing
  const timers = useRef<number[]>([]);
  const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)); };
  const stop = () => { timers.current.forEach((t) => window.clearTimeout(t)); timers.current = []; };

  const shown = text.toUpperCase();
  const set = useMemo(() => setType(shown, COLS), [shown]);

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const fit = () => setK(Math.max(0.5, Math.min(1.08, (el.clientWidth - 16) / W)));
    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(el);
    // The board leans a few degrees towards the pointer.
    const move = (e: PointerEvent) => { const r = el.getBoundingClientRect(); el.style.setProperty("--ry", `${(((e.clientX - r.left) / r.width - 0.5) * 9).toFixed(2)}deg`); el.style.setProperty("--rx", `${((0.5 - (e.clientY - r.top) / r.height) * 6).toFixed(2)}deg`); };
    const leave = () => { el.style.setProperty("--ry", "0deg"); el.style.setProperty("--rx", "0deg"); };
    el.addEventListener("pointermove", move); el.addEventListener("pointerleave", leave);
    return () => { ro.disconnect(); el.removeEventListener("pointermove", move); el.removeEventListener("pointerleave", leave); };
  }, []);

  useEffect(() => {
    if (reduce) { setText(pages[0].sentence); setPage(pages[0]); return; }
    const play = (i: number, from: number) => {
      const s = pages[i].sentence;
      let n = from;
      setPage(null); setText(s.slice(0, n));
      const key = () => {
        if (mine.current) return;
        n += 1; setText(s.slice(0, n));
        if (n < s.length) later(key, 40 + ((n * 43) % 50));
        else later(() => { setPage(pages[i]); setSaid(`Page set: ${pages[i].head.join(" ")}`); later(() => { setText(""); play((i + 1) % pages.length, 0); }, HOLD); }, from ? 160 : 420);
      };
      later(key, from ? 70 : 900);
    };
    play(0, pages[0].sentence.length - 3);
    return stop;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [reduce]);

  const take = () => { mine.current = true; stop(); setPage(null); setText(""); setCaret(-1); setSaid(""); };
  const submit = () => {
    if (page) { take(); field.current?.focus(); return; }
    if (!text.trim()) { field.current?.focus(); return; }
    const demo = pages.find((p) => p.sentence === text);
    const next = demo ?? pageFrom(text);
    mine.current = true; stop();
    setPage(next); setSaid(`Page set: ${next.head.join(" ")}`);
  };
  const onKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => { if (e.key === "Enter") { e.preventDefault(); submit(); } };
  const onSelect = (e: SyntheticEvent<HTMLTextAreaElement>) => setCaret(e.currentTarget.selectionStart ?? -1);
  const onChange = (value: string, where: number) => {
    const v = value.replace(/\n/g, "");
    if (setType(v.toUpperCase(), COLS).rows > ROWS) return; // the board is full
    mine.current = true; stop(); setPage(null); setText(v); setCaret(where);
  };

  const ci = caret < 0 || caret > shown.length ? shown.length : caret;
  const cpos = ci < shown.length ? set.at[ci] : set.end;
  const ease = reduce ? { duration: 0 } : undefined;
  const side: CSSProperties = { position: "absolute" };

  return (
    <div ref={root} className="relative flex min-h-[480px] w-full items-center justify-center overflow-hidden bg-[#DAD5CB] py-4 sm:min-h-[770px]" style={{ fontFamily: FONT, backgroundImage: "radial-gradient(80% 70% at 30% 10%,#efebe3 0%,rgba(239,235,227,0) 62%), linear-gradient(180deg,rgba(218,213,203,0) 55%,#c4beb1 100%)" }}>
      <style>{`
        @keyframes felt-letter-board-caret { 0%,54% { opacity: 1 } 55%,100% { opacity: 0.15 } }
        .felt-letter-board-field::selection { background: transparent; }
        @media (prefers-reduced-motion: reduce) { .felt-letter-board-anim { animation: none !important; } .felt-letter-board-tilt { transform: none !important; } }
      `}</style>
      <div style={{ width: W * k, height: H * k }}>
        <div className="relative" style={{ width: W, height: H, transform: `scale(${k})`, transformOrigin: "0 0" }}>
          <div className="felt-letter-board-tilt absolute left-[62px] top-[18px] h-[560px] w-[436px]" style={{ transform: "perspective(1500px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg))", transition: "transform 0.6s cubic-bezier(0.2,0.8,0.2,1)" }}>
            {/* Shadow on the wall, then the four mitred lengths of oak. */}
            <div aria-hidden="true" className="absolute inset-[6px] rounded-[4px]" style={{ boxShadow: "0 50px 60px -18px rgba(50,40,20,0.55), 0 14px 18px -4px rgba(50,40,20,0.35), 16px 30px 50px -10px rgba(50,40,20,0.25)" }} />
            <div aria-hidden="true" style={{ ...side, left: 0, top: 0, width: 436, height: 20, clipPath: "polygon(0 0,100% 0,calc(100% - 20px) 100%,20px 100%)", backgroundImage: `${GRAIN_H}, linear-gradient(180deg,#f3dcb8 0%,#dfbf93 22%,#c9a472 100%)` }} />
            <div aria-hidden="true" style={{ ...side, left: 0, bottom: 0, width: 436, height: 20, clipPath: "polygon(20px 0,calc(100% - 20px) 0,100% 100%,0 100%)", backgroundImage: `${GRAIN_H}, linear-gradient(180deg,#c39c69 0%,#a9814f 80%,#7d5c33 100%)`, backgroundPosition: "-90px -30px" }} />
            <div aria-hidden="true" style={{ ...side, left: 0, top: 0, width: 20, height: 560, clipPath: "polygon(0 0,100% 20px,100% calc(100% - 20px),0 100%)", backgroundImage: `${GRAIN_V}, linear-gradient(90deg,#ecd3ac 0%,#d2ae7e 30%,#bd9764 100%)` }} />
            <div aria-hidden="true" style={{ ...side, right: 0, top: 0, width: 20, height: 560, clipPath: "polygon(0 20px,100% 0,100% 100%,0 calc(100% - 20px))", backgroundImage: `${GRAIN_V}, linear-gradient(90deg,#c7a06c 0%,#b08652 70%,#8b673a 100%)`, backgroundPosition: "-30px -60px" }} />

            {/* The felt, with its ribs. Letters that fall are cut off at its edge. */}
            <div className="absolute left-[20px] top-[20px] bg-[#0B0D12]" style={{ width: FELT_W, height: FELT_H, clipPath: "inset(0)", backgroundImage: `radial-gradient(90% 70% at 22% 6%,rgba(255,255,255,0.1),rgba(255,255,255,0) 60%), ${FELT}, repeating-linear-gradient(180deg,rgba(255,255,255,0.075) 0 1px,rgba(255,255,255,0) 1px 8px,rgba(0,0,0,0.75) 8px 12px,rgba(0,0,0,0) 12px 13px)` }}>
              {/* The request: a blue heading, the sentence as it is typed, and a hint. */}
              <div aria-hidden="true" className="absolute inset-0">
                <div className="absolute inset-0 transition-opacity duration-200" style={{ opacity: page ? 0 : 1 }}>
                  {"WHAT SHOULD WE BUILD?".split("").map((ch, i) => ch === " " ? null : <span key={i} className="absolute block text-center font-extrabold" style={{ left: PAD + i * 12, top: 28 + wobble(i * 5) * 0.8, width: 12, fontSize: 13.5, lineHeight: 1, color: BLUE, textShadow: INKED, transform: `rotate(${wobble(i) * 2.4}deg)` }}>{ch}</span>)}
                  {/* The caret is one blue peg. */}
                  <span className="felt-letter-board-anim absolute block rounded-[2px]" style={{ left: SX + Math.min(cpos.c, COLS - 1) * CELL + (cpos.c >= COLS ? CELL - 4 : 2), top: SY + Math.min(cpos.r, ROWS - 1) * PITCH - 1, width: 5, height: 24, background: `linear-gradient(180deg,#b9d8ff,${BLUE} 40%,#2f74dc)`, boxShadow: `0 3px 4px rgba(0,0,0,0.9), 0 0 12px rgba(79,152,251,0.8)`, animation: "felt-letter-board-caret 1.1s steps(1) infinite" }} />
                  {"PRESS ENTER TO SET THE PAGE".split("").map((ch, i) => ch === " " ? null : <span key={i} className="absolute block text-center font-extrabold" style={{ left: PAD + i * 11, top: 470 + wobble(i * 13) * 0.8, width: 11, fontSize: 12, lineHeight: 1, color: "#bdb9ae", textShadow: WHITE, transform: `rotate(${wobble(i * 2) * 2.4}deg)` }}>{ch}</span>)}
                </div>
                <AnimatePresence>
                  {(page ? "" : shown).split("").map((ch, i) => ch === " " ? null : (
                    <motion.span key={`${i}${ch}`} className="absolute left-0 top-0 block text-center font-extrabold" style={{ width: CELL, fontSize: 24, lineHeight: 1, color: "#F6F4EF", textShadow: WHITE }} initial={reduce ? false : { opacity: 0, scale: 1.8, x: SX + set.at[i].c * CELL, y: SY + set.at[i].r * PITCH }} animate={{ opacity: 1, scale: 1, rotate: wobble(i) * 2.6, x: SX + set.at[i].c * CELL, y: SY + set.at[i].r * PITCH + wobble(i * 3) }} exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { y: FELT_H + 40, rotate: wobble(i) * 80, transition: { duration: 0.46 + Math.abs(wobble(i * 5)) * 0.24, delay: Math.abs(wobble(i * 11)) * 0.14, ease: [0.55, 0, 1, 0.55] } }} transition={ease ?? { type: "spring", stiffness: 540, damping: 26 }}>{ch}</motion.span>
                  ))}
                </AnimatePresence>
              </div>
              <AnimatePresence>
                {page && (
                  <motion.div key={`page-${page.brand}-${page.head.join("")}`} aria-hidden="true" className="absolute inset-0" variants={{ hide: {}, show: {}, gone: { opacity: 0.999, transition: { duration: 0.85 } } }} initial={reduce ? false : "hide"} animate="show" exit={reduce ? undefined : "gone"}>
                    <Line text={page.brand} y={26} cell={12.5} size={14} row={0} />
                    <Line text={page.nav.join("  ")} y={28} cell={9} size={10.5} row={0} dim right />
                    {page.head.map((h, i) => <Line key={h + i} text={h} y={70 + i * 52} cell={34} size={41} row={1 + i} />)}
                    <Line text={page.sub} y={236} cell={11.5} size={12.5} row={4} blue />
                    <motion.div variants={{ hide: { opacity: 0, scale: 1.25, rotate: -9 }, show: { opacity: 1, scale: 1, rotate: -2.4, y: 0, transition: { delay: 0.8, type: "spring", stiffness: 300, damping: 20 } }, gone: { y: FELT_H, rotate: 24, transition: { duration: 0.6, ease: [0.55, 0, 1, 0.55] } } }} className="absolute left-[22px] top-[274px] h-[148px] w-[196px] p-[6px]" style={{ background: "linear-gradient(180deg,#fdfcf9,#ebe7dd)", boxShadow: "0 12px 14px -4px rgba(0,0,0,0.9), 0 1px 0 #fff inset" }}>
                      <div className="relative h-full w-full overflow-hidden" style={{ background: page.src ? "#6b5a48" : `linear-gradient(135deg,#2f74dc,${BLUE} 55%,#b9d8ff)` }}>
                        {page.src ? <img src={page.src} alt={page.alt} width={640} height={427} className="h-full w-full object-cover" style={{ objectPosition: page.focus }} /> : (
                          <span className="absolute inset-0" style={{ backgroundImage: "linear-gradient(rgba(255,255,255,0.22) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.22) 1px,transparent 1px)", backgroundSize: "18px 18px" }}>
                            <span className="absolute left-[14px] top-[16px] h-[46px] w-[70px] rounded-[5px] border-[1.5px] border-white/80" />
                            <span className="absolute left-[96px] top-[18px] h-[7px] w-[68px] rounded-full bg-white/85" />
                            <span className="absolute left-[96px] top-[32px] h-[7px] w-[46px] rounded-full bg-white/55" />
                            <span className="absolute left-[14px] top-[78px] h-[40px] w-[156px] rounded-[5px] border-[1.5px] border-dashed border-white/70" />
                          </span>
                        )}
                        <span className="absolute inset-0" style={{ background: "linear-gradient(115deg,rgba(255,255,255,0.28),rgba(255,255,255,0) 34%)" }} />
                      </div>
                    </motion.div>
                    {page.facts.map((f, i) => <Line key={f} text={f} x={236} y={292 + i * 39} cell={10} size={11.5} row={5 + i} dim={i > 0} />)}
                    <motion.div variants={{ hide: { opacity: 0, scale: 1.3 }, show: { opacity: 1, scale: 1, y: 0, rotate: 0.8, transition: { delay: 1.05, type: "spring", stiffness: 420, damping: 22 } }, gone: { y: FELT_H, rotate: -30, transition: { duration: 0.55, ease: [0.55, 0, 1, 0.55] } } }} className="absolute left-[22px] top-[448px] flex h-[44px] items-center rounded-full px-[22px] text-[16px] font-extrabold tracking-[-0.02em] text-[#0B0D12]" style={{ background: `linear-gradient(180deg,#a9d0ff 0%,${BLUE} 38%,#3d83ea 100%)`, boxShadow: "0 8px 10px -2px rgba(0,0,0,0.9), 0 2px 0 #1f5fc4, inset 0 1.5px 0 rgba(255,255,255,0.75), 0 0 26px rgba(79,152,251,0.35)" }}>{page.cta}</motion.div>
                  </motion.div>
                )}
              </AnimatePresence>

              <label htmlFor="felt-letter-board-field" className="sr-only">Describe what you want built in one sentence</label>
              <textarea
                id="felt-letter-board-field"
                ref={field}
                value={text}
                maxLength={MAX}
                spellCheck={false}
                autoCapitalize="characters"
                onFocus={() => { setFocus(true); if (!mine.current || page) take(); }}
                onBlur={() => setFocus(false)}
                onChange={(e) => onChange(e.target.value, e.target.selectionStart ?? -1)}
                onSelect={onSelect}
                onKeyDown={onKey}
                className="felt-letter-board-field absolute inset-0 h-full w-full cursor-text resize-none overflow-hidden border-0 bg-transparent p-[20px] text-[16px] text-transparent outline-none"
                style={{ caretColor: "transparent" }}
              />
              <span aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ boxShadow: `inset 0 8px 14px rgba(0,0,0,0.95), inset 5px 0 9px rgba(0,0,0,0.6), inset -3px 0 6px rgba(0,0,0,0.5), inset 0 -1px 0 rgba(255,255,255,0.12)${focus ? `, inset 0 0 0 2px ${BLUE}` : ""}` }} />
            </div>
          </div>

          <button type="button" onClick={submit} className="absolute left-1/2 top-[596px] flex h-[48px] w-[212px] -translate-x-1/2 items-center justify-center gap-[10px] rounded-full text-[15px] font-extrabold tracking-[-0.02em] text-white outline-none transition-transform duration-100 focus-visible:ring-2 focus-visible:ring-[#0B0D12] focus-visible:ring-offset-2 focus-visible:ring-offset-[#DAD5CB] active:translate-y-[1px]" style={{ background: "linear-gradient(180deg,#2a2e37 0%,#12141a 46%,#07080b 100%)", boxShadow: "0 12px 18px -8px rgba(40,30,15,0.7), inset 0 1px 0 rgba(255,255,255,0.28), inset 0 -2px 3px rgba(0,0,0,0.8)", fontFamily: FONT }}>
            <span aria-hidden="true" className="h-[8px] w-[8px] rounded-full" style={{ background: BLUE, boxShadow: `0 0 8px 1px ${BLUE}` }} />
            {page ? "Clear the board" : "Set the page"}
          </button>
          <p className="absolute left-0 top-[662px] w-full text-center text-[12px] font-semibold tracking-[-0.005em] text-[#4a4840]">Click the board and type a sentence</p>
          <p className="sr-only" role="status">{said}</p>
        </div>
      </div>
    </div>
  );
}
```
