// components/ui/text-flipping-board.tsx "use client"; import React, { useEffect, useRef, useState, useMemo } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; const FLAP_CHARS = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$()-+&=;:'\"%,./?°"; const BOARD_ROWS = 6; const BOARD_COLS = 22; const BASE_COL_DELAY = 30; const BASE_ROW_DELAY = 20; const BASE_STEP_MS = 55; const BASE_FLIP_S = 0.35; const BASE_TOTAL_S = ((BOARD_COLS - 1) * BASE_COL_DELAY + (BOARD_ROWS - 1) * BASE_ROW_DELAY + 8 * BASE_STEP_MS) / 1000; type AccentColor = { top: string; bottom: string; text: string; }; const ACCENT_COLORS: AccentColor[] = [ { top: "bg-red-600", bottom: "bg-red-700", text: "text-white" }, { top: "bg-orange-500", bottom: "bg-orange-600", text: "text-white" }, { top: "bg-yellow-400", bottom: "bg-yellow-500", text: "text-neutral-900" }, { top: "bg-green-600", bottom: "…
查看原始来源 ↗
Prompt
// components/ui/text-flipping-board.tsx
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
const FLAP_CHARS = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$()-+&=;:'\"%,./?°";
const BOARD_ROWS = 6;
const BOARD_COLS = 22;
const BASE_COL_DELAY = 30;
const BASE_ROW_DELAY = 20;
const BASE_STEP_MS = 55;
const BASE_FLIP_S = 0.35;
const BASE_TOTAL_S =
((BOARD_COLS - 1) * BASE_COL_DELAY +
(BOARD_ROWS - 1) * BASE_ROW_DELAY +
8 * BASE_STEP_MS) /
1000;
type AccentColor = {
top: string;
bottom: string;
text: string;
};
const ACCENT_COLORS: AccentColor[] = [
{ top: "bg-red-600", bottom: "bg-red-700", text: "text-white" },
{ top: "bg-orange-500", bottom: "bg-orange-600", text: "text-white" },
{ top: "bg-yellow-400", bottom: "bg-yellow-500", text: "text-neutral-900" },
{ top: "bg-green-600", bottom: "bg-green-700", text: "text-white" },
{ top: "bg-blue-600", bottom: "bg-blue-700", text: "text-white" },
{ top: "bg-violet-600", bottom: "bg-violet-700", text: "text-white" },
{ top: "bg-white", bottom: "bg-neutral-100", text: "text-neutral-900" },
];
const CELL_TEXT_STYLE: React.CSSProperties = {
fontSize: "clamp(6px, 2vw, 22px)",
lineHeight: 1,
};
// ── Individual Split-Flap Character ───────────────────────────────────
const FlapCell = React.memo(function FlapCell({
target,
delay,
stepMs,
flipDuration,
}: {
target: string;
delay: number;
stepMs: number;
flipDuration: number;
}) {
const [current, setCurrent] = useState(" ");
const [prev, setPrev] = useState(" ");
const [flipId, setFlipId] = useState(0);
const [accent, setAccent] = useState<AccentColor | null>(null);
const [prevAccent, setPrevAccent] = useState<AccentColor | null>(null);
const curRef = useRef(" ");
const tgtRef = useRef<string | null>(null);
const accentRef = useRef<AccentColor | null>(null);
const startTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const stepTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (startTimer.current) clearTimeout(startTimer.current);
if (stepTimer.current) clearTimeout(stepTimer.current);
startTimer.current = null;
stepTimer.current = null;
const normalized = FLAP_CHARS.includes(target.toUpperCase())
? target.toUpperCase()
: " ";
if (normalized === tgtRef.current) return;
tgtRef.current = normalized;
if (normalized === " " && curRef.current === " ") return;
const scrambleCount =
normalized === " "
? 8 + Math.floor(Math.random() * 8)
: 25 + Math.floor(Math.random() * 15);
const runStep = (i: number) => {
const isLast = i === scrambleCount;
const ch = isLast
? normalized
: FLAP_CHARS[1 + Math.floor(Math.random() * (FLAP_CHARS.length - 1))];
const newAccent = isLast
? null
: Math.random() < 0.2
? ACCENT_COLORS[Math.floor(Math.random() * ACCENT_COLORS.length)]
: null;
setPrev(curRef.current);
setPrevAccent(accentRef.current);
curRef.current = ch;
accentRef.current = newAccent;
setCurrent(ch);
setAccent(newAccent);
setFlipId((n) => n + 1);
if (!isLast) {
stepTimer.current = setTimeout(() => runStep(i + 1), stepMs);
}
};
startTimer.current = setTimeout(() => runStep(1), delay);
return () => {
if (startTimer.current) clearTimeout(startTimer.current);
if (stepTimer.current) clearTimeout(stepTimer.current);
startTimer.current = null;
stepTimer.current = null;
tgtRef.current = null;
};
}, [target, delay, stepMs]);
const show = current === " " ? "\u00A0" : current;
const showPrev = prev === " " ? "\u00A0" : prev;
const textCx =
"absolute inset-x-0 flex select-none items-center justify-center font-mono font-bold tracking-wide";
const topBg = accent?.top ?? "bg-neutral-200/80 dark:bg-neutral-900";
const bottomBg = accent?.bottom ?? "bg-neutral-200/80 dark:bg-neutral-900";
const textColor = accent?.text ?? "text-neutral-800 dark:text-white";
const flapTopBg = prevAccent?.top ?? "bg-neutral-100 dark:bg-neutral-800";
const flapTextColor = prevAccent?.text ?? "text-neutral-800 dark:text-white";
const bottomDelay = flipDuration * 0.5;
return (
<div className="flex aspect-3/6 flex-col overflow-hidden rounded-[2px] border border-neutral-300 md:rounded-[3px] md:border-2 dark:border-black">
{/* Flap content area */}
<div className="relative flex-1 perspective-dramatic transform-3d">
<div className="absolute inset-0 z-40 hidden flex-row items-center justify-center md:flex">
<div className="h-1/2 w-px rounded-tr-sm rounded-br-sm bg-neutral-300 dark:bg-black" />
<div className="flex h-px flex-1 bg-neutral-300 dark:bg-black" />
<div className="h-1/2 w-px rounded-tl-sm rounded-bl-sm bg-neutral-300 dark:bg-black" />
</div>
{/* Static top – new character top half */}
<div
className={cn(
"absolute inset-x-0 top-0 h-[calc(50%-0.5px)] overflow-hidden rounded-t-[3px]",
topBg,
)}
>
<div
className={cn(textCx, textColor, "top-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{show}
</div>
</div>
{/* Static bottom – new character bottom half */}
<div
className={cn(
"absolute inset-x-0 bottom-0 h-[calc(50%-0.5px)] overflow-hidden rounded-b-[3px]",
bottomBg,
)}
>
<div
className={cn(textCx, textColor, "bottom-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{show}
</div>
{flipId > 0 && (
<motion.div
key={`s${flipId}`}
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0.8),transparent_60%)] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0.8),transparent_60%)]"
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
transition={{ duration: flipDuration * 1.3, ease: "easeOut" }}
/>
)}
</div>
{/* Flipping top flap – old character top half, drops down */}
{flipId > 0 && (
<motion.div
key={flipId}
className={cn(
"absolute inset-x-0 top-0 z-10 h-[calc(50%-0.5px)] origin-bottom overflow-hidden rounded-t-[3px] backface-hidden transform-3d",
flapTopBg,
)}
initial={{ rotateX: 0 }}
animate={{ rotateX: -100 }}
transition={{
duration: flipDuration,
ease: [0.55, 0.055, 0.675, 0.19],
}}
>
<div
className={cn(textCx, flapTextColor, "top-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{showPrev}
</div>
<motion.div
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0),rgba(255,255,255,1))] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0),rgba(0,0,0,1))]"
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
transition={{ duration: flipDuration }}
/>
</motion.div>
)}
{/* Flipping bottom flap – new character bottom half, rises up */}
{flipId > 0 && (
<motion.div
key={`b${flipId}`}
className={cn(
"absolute inset-x-0 bottom-0 z-10 h-[calc(50%-0.5px)] origin-top overflow-hidden rounded-b-[3px] backface-hidden transform-3d",
bottomBg,
)}
initial={{ rotateX: 90 }}
animate={{ rotateX: 0 }}
transition={{
duration: flipDuration * 0.85,
delay: bottomDelay,
ease: [0.33, 1.55, 0.64, 1],
}}
>
<div
className={cn(textCx, textColor, "bottom-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{show}
</div>
<motion.div
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(255,255,255,0),rgba(255,255,255,0.6))] dark:bg-[linear-gradient(to_top,rgba(0,0,0,0),rgba(0,0,0,0.6))]"
initial={{ opacity: 0.4 }}
animate={{ opacity: 0 }}
transition={{
duration: flipDuration * 0.85,
delay: bottomDelay,
}}
/>
</motion.div>
)}
{/* Split line */}
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-20 h-px -translate-y-[0.5px] bg-neutral-400/50 dark:bg-black/50" />
</div>
{/* Bottom stripes – decorative, outside the flap area */}
<div className="h-2 w-full bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.15rem)] mask-t-from-50% text-neutral-400 opacity-20 md:h-4 md:bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.2rem)] dark:text-black dark:opacity-100" />
</div>
);
},
(prevProps, nextProps) =>
prevProps.target === nextProps.target &&
prevProps.delay === nextProps.delay &&
prevProps.stepMs === nextProps.stepMs &&
prevProps.flipDuration === nextProps.flipDuration,
);
// ── Color Tile ────────────────────────────────────────────────────────
const COLOR_MAP: Record<string, string> = {
"{R}": "#D32F2F",
"{O}": "#F57C00",
"{Y}": "#FBC02D",
"{G}": "#43A047",
"{B}": "#1E88E5",
"{V}": "#8E24AA",
"{W}": "#FAFAFA",
};
const ColorCell = React.memo(function ColorCell({ color }: { color: string }) {
return (
<div
className="aspect-3/5 rounded-[3px] border-2 border-neutral-300 dark:border-black"
style={{ backgroundColor: color }}
/>
);
});
// ── Row Parser ────────────────────────────────────────────────────────
type ParsedCell =
| { type: "char"; value: string }
| { type: "color"; hex: string };
function parseRow(row: string): ParsedCell[] {
const cells: ParsedCell[] = [];
let i = 0;
while (i < row.length) {
if (row[i] === "{" && i + 2 < row.length && row[i + 2] === "}") {
const code = row.substring(i, i + 3);
if (COLOR_MAP[code]) {
cells.push({ type: "color", hex: COLOR_MAP[code] });
i += 3;
continue;
}
}
cells.push({ type: "char", value: row[i] });
i++;
}
return cells;
}
// ── Word Wrap ─────────────────────────────────────────────────────────
function wrapParagraph(paragraph: string, maxCols: number): string[] {
const lines: string[] = [];
const words = paragraph.split(/[ \t]+/).filter(Boolean);
let currentLine = "";
for (const word of words) {
if (word.length > maxCols) {
if (currentLine) {
lines.push(currentLine);
currentLine = "";
}
lines.push(word.slice(0, maxCols));
continue;
}
if (!currentLine) {
currentLine = word;
} else if (currentLine.length + 1 + word.length <= maxCols) {
currentLine += " " + word;
} else {
lines.push(currentLine);
currentLine = word;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
function wrapText(input: string, maxCols: number): string[] {
return input
.split("\n")
.flatMap((paragraph) =>
paragraph.trim() === "" ? [""] : wrapParagraph(paragraph, maxCols),
);
}
// ── Main TextFlippingBoard Component ──────────────────────────────────
export interface TextFlippingBoardProps {
rows?: string[];
text?: string;
className?: string;
/** Total animation duration in seconds. Defaults to ~1.2s. */
duration?: number;
}
export function TextFlippingBoard({
rows,
text,
className,
duration = BASE_TOTAL_S,
}: TextFlippingBoardProps) {
const scale = duration / BASE_TOTAL_S;
const colDelay = BASE_COL_DELAY * scale;
const rowDelay = BASE_ROW_DELAY * scale;
const stepMs = BASE_STEP_MS * scale;
const flipDur = Math.min(0.6, Math.max(0.15, BASE_FLIP_S * scale));
const board = useMemo(() => {
const grid: ParsedCell[][] = Array.from({ length: BOARD_ROWS }, () =>
Array.from({ length: BOARD_COLS }, () => ({
type: "char" as const,
value: " ",
})),
);
if (text) {
const lines = wrapText(text, BOARD_COLS).slice(0, BOARD_ROWS);
const startRow = Math.max(0, Math.floor((BOARD_ROWS - lines.length) / 2));
lines.forEach((line, i) => {
const row = startRow + i;
if (row >= BOARD_ROWS) return;
const parsed = parseRow(line);
const startCol = Math.max(
0,
Math.floor((BOARD_COLS - parsed.length) / 2),
);
parsed.forEach((cell, c) => {
if (startCol + c < BOARD_COLS) {
grid[row][startCol + c] = cell;
}
});
});
} else if (rows) {
rows.forEach((row, r) => {
if (r >= BOARD_ROWS) return;
const parsed = parseRow(row);
parsed.forEach((cell, c) => {
if (c < BOARD_COLS) {
grid[r][c] = cell;
}
});
});
}
return grid;
}, [rows, text]);
return (
<div
className={cn(
"relative mx-auto w-full max-w-3xl rounded-xl bg-neutral-100 p-2 shadow-xl md:rounded-2xl md:p-4 dark:bg-neutral-900 dark:shadow-[0_20px_70px_-15px_rgba(0,0,0,0.6)]",
className,
)}
>
<div
className="grid gap-px md:gap-[3px]"
style={{ gridTemplateColumns: `repeat(${BOARD_COLS}, 1fr)` }}
>
{board.map((row, r) =>
row.map((cell, c) =>
cell.type === "color" ? (
<ColorCell key={`${r}-${c}`} color={cell.hex} />
) : (
<FlapCell
key={`${r}-${c}`}
target={cell.value}
delay={c * colDelay + r * rowDelay}
stepMs={stepMs}
flipDuration={flipDur}
/>
),
),
)}
</div>
</div>
);
}复用方法
关键决定
观察最终结果里,页面信息层级、模块顺序和 CTA 是否一口气讲清楚 是否同时成立。
换到你的题材
把“目标结果 → 产品名称、目标用户、页面模块和主按钮 → 约束条件”抽成模板,后续可复用到同类任务。
容易翻车
围绕 模块顺序、价值主张写法与 CTA 位置 做单变量对照,并记录模型、成本和失败结果;重复成立后再沉淀为 Skill。
建议模型
创作者
Aceternity UI 长期扎根Aceternity UI,交付可运行的界面,《Parallax Hero Images》配了完整实现思路。
继续探索
@Oluwaphilemon1 使用 Claude Fable 5 · Three.js · Blender · GSAP完成的网页案例,包含公开结果、完整 Prompt 与原始来源。
提示语
Claude Fable 5 和 GPT-5.6 很强🥵🥵🥵 下面是我如何使用 Claude 构建的 👇 提示词:「设计一个机构网站,把服务以 3D 超市商品包装的形式呈现」 使用 Three.js 和 WebGL 在浏览器中实时渲染所有产品 使用 Blender 制作包装模型,然后导出为 GLTF 以加载到 Three.js 中 使用 GSAP 实现流畅的产品旋转和悬停交互 如果你想做一个让客户感觉像是在购买高级商品的作品集,请保存这条 🛒
// components/ui/3d-pin.tsx "use client"; import React, { useState } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export const PinContainer = ({ children, title, href, className, containerClassName, }: { children: React.ReactNode; title?: string; href?: string; className?: string; containerClassName?: string; }) => { const [transform, setTransform] = useState( "translate(-50%,-50%) rotateX(0deg)" ); const onMouseEnter = () => { setTransform("translate(-50%,-50%) rotateX(40deg) scale(0.8)"); }; const onMouseLeave = () => { setTransform("translate(-50%,-50%) rotateX(0deg) scale(1)"); }; return ( <a className={cn( "relative group/pin z-50 cursor-pointer", containerClassName )} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} href={href || "/"} > <div style={{ perspective: "1000px", transform: "rotateX(70deg) translateZ(0deg)", }} className="…
提示语
// components/ui/3d-pin.tsx "use client"; import React, { useState } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export const PinContainer = ({ children, title, href, className, containerClassName…
.webp)
Build a single full-viewport hero section in React + TypeScript + Vite + Tailwind CSS, using `lucide-react` for icons. The component is a character-figurine carousel called "TOONHUB". **Fonts (load in `index.html` head):** ```html <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=Anton&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> ``` Body font: `'Inter', sans-serif`. Display font (huge ghost text + bottom-right link): `'Anton', sans-serif`. **Image data (4 items, exact URLs and colors):** ```ts const IMAGES = [ { src: 'https://fifth-gentle-45902158.figma.site/_components/v2/4de492f6d9cf8244ad5293233e5c6f52407d42fc/1.02464a56.png', bg: '#F4845F', panel: '#F79B7F' }, { src: 'https://fifth-gentle-45902158.figma.site/_components/v2/4d…
提示语
Build a single full-viewport hero section in React + TypeScript + Vite + Tailwind CSS, using `lucide-react` for icons. The component is a character-figurine carousel called "TOONHUB". **Fonts (load in `index.html` head):** ```html <link re…