首屏打字机改为固定字号 + CSS 光标,并修掉首屏白屏

按 Firefly banner 的做法重做,三件事:

1) 字号固定,不随句子长度变
   - 文案从 4 句(14/11/4/4 字,差 3.5 倍)压成 2 句长度接近的王小波
   - 字号用 text-[clamp(1.125rem,5.6vw,4rem)],只跟视口缩放
     · 手机 390px → 21.8px(15 个字一行刚好放得下)
     · 桌面 1280px → 64px(比 Firefly 的 3rem 更大更醒目)
   - 容器 max-w-4xl(896px) → max-w-5xl(1024px):15 字 × 64px = 954px,4xl 装不下
   - 实测 320/360/390/430/640/768/1024/1280/1920 全部放得下,无溢出

2) 光标改成 CSS ::after
   - 删掉整套运行时测量:measureRef / designRef / fontSize state /
     fitFontSize / ResizeObserver / CURSOR_EM / CURSOR_GAP_PX / 两个隐藏量具
   - hero.tsx 298 → 208 行,两个文件净减 52 行
   - 顺带消除测量误差,光标跟着字号自动缩放

3) 去掉和打字机重复的 Flannery O'Connor 引语

另外修掉两个自己发现的问题:

- 【真 bug】首屏在 JS 加载前是隐形的
  SSR 出来的包裹层带 opacity-0,要等 React hydrate 才变可见,
  慢网络下第一屏是白屏、LCP 被拖后。改成 CSS 入场动画(.hero-enter),
  SSR 即可见;顺带删掉只为它存在的 mounted state

- 【算错】第一版 clamp 封顶写了 4.25rem(68px),但容器 896px 装不下
  1013px,≥1280px 会溢出。一起修掉了

验证:pnpm build 退出码 0;dist 里已无 hero-cursor span、无隐藏量具、
无 Flannery 引语,h1 class 为 hero-typewriter + clamp 字号
This commit is contained in:
dsh
2026-09-16 17:47:20 +08:00
parent c597427b9a
commit ee5ec50602
3 changed files with 85 additions and 137 deletions
+30 -125
View File
@@ -3,29 +3,33 @@
import { useEffect, useRef, useState, useCallback } from "react" import { useEffect, useRef, useState, useCallback } from "react"
import { site } from "@/data/site" import { site } from "@/data/site"
/**
* 打字机文案。**只放长度接近的短句** —— 字号是固定的,靠"文案等长"来
* 保证不溢出,而不是靠运行时测量(见下方 HERO_SIZE_CLASSES 的说明)。
* 原来那四句是 14/11/4/4 字,长短差 3.5 倍,只能靠自适应字号兜底,
* 结果是每换一句首屏最大的字就跳 1.5 倍。
*/
const phrases = [ const phrases = [
{ prefix: "一个人只拥有此生此世", highlight: "是不够的" }, { prefix: "一个人只拥有此生此世", highlight: "是不够的," },
{ prefix: "他还需要一个", highlight: "诗意的世界" }, { prefix: "他还需要一个", highlight: "诗意的世界。" },
{ prefix: "记录", highlight: "思考" },
{ prefix: "总结", highlight: "生活" },
] ]
/** /**
* 首屏大字的「设计字号」:手机 / sm / md / lg 四档。 * 首屏大字字号:**固定按视口缩放,不随句子长度变**。
* 自适应只会在此基础上**缩小**,不会放大 —— 短句保持原来的视觉分量, *
* 只有放不下的长句才让步。改这里两处要同步(h1 和下面量尺寸的探针)。 * 这是从 Firefly 的 banner 学来的做法:那边 subtitleSize 写死 3rem,
* CSS 里再用 clamp() 跟着视口缩放,配两条长度接近的文案就永远不会溢出,
* 也就不需要任何运行时测量。
*
* 取值依据:最长那句 15 个全角字 ≈ 14.6em(含 tracking-tight 的 -0.025em),
* 加光标约 0.3em + 2px,所以可用宽度 / 14.9 就是字号上限。
* 320px 屏 → 约 18px 390px → 约 22px 768px → 约 43px
* 1024px → 约 57px 1280px 及以上 → 68px(clamp 封顶)
* 移动端刻意压小以保证 15 个字一行放得下;桌面端放大到 68px 更醒目。
*/ */
const HERO_SIZE_CLASSES = "text-[min(10.5vw,3rem)] sm:text-6xl md:text-7xl lg:text-8xl" const HERO_SIZE_CLASSES = "text-[clamp(1.125rem,5.6vw,4rem)]"
/** 缩放的绝对下限,防止句子太长时缩到读不清 */
const HERO_MIN_FONT = 20
/** 光标的占位:ml-2 的 8px + w-[0.08em],算字号时要一起扣掉 */
const CURSOR_GAP_PX = 8
const CURSOR_EM = 0.08
export function Hero() { export function Hero() {
const [mounted, setMounted] = useState(false)
const [phraseIndex, setPhraseIndex] = useState(0) const [phraseIndex, setPhraseIndex] = useState(0)
const [displayText, setDisplayText] = useState("") const [displayText, setDisplayText] = useState("")
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
@@ -35,10 +39,6 @@ export function Hero() {
const [isActive, setIsActive] = useState(true) const [isActive, setIsActive] = useState(true)
const sectionRef = useRef<HTMLElement | null>(null) const sectionRef = useRef<HTMLElement | null>(null)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => { useEffect(() => {
const onVisibility = () => { const onVisibility = () => {
if (document.hidden) setIsActive(false) if (document.hidden) setIsActive(false)
@@ -110,7 +110,7 @@ export function Hero() {
}, [displayText, isDeleting, phraseIndex, getTypeSpeed, getDeleteSpeed]) }, [displayText, isDeleting, phraseIndex, getTypeSpeed, getDeleteSpeed])
useEffect(() => { useEffect(() => {
if (!mounted || !isActive) return if (!isActive) return
const timer = window.setTimeout(typeWriter, 50) const timer = window.setTimeout(typeWriter, 50)
return () => { return () => {
window.clearTimeout(timer) window.clearTimeout(timer)
@@ -119,7 +119,7 @@ export function Hero() {
innerTimerRef.current = null innerTimerRef.current = null
} }
} }
}, [mounted, isActive, typeWriter]) }, [isActive, typeWriter])
const renderText = () => { const renderText = () => {
const currentPhrase = phrases[phraseIndex] const currentPhrase = phrases[phraseIndex]
@@ -147,120 +147,25 @@ export function Hero() {
} }
}, []) }, [])
/*
* 字号自适应。
*
* 原来写死 text-[min(10.5vw,3rem)] sm:text-6xl …,配合 whitespace-nowrap:
* 句子一长(旧文案约 9.5 字宽就已经临界)就会横向溢出,手机上尤其明显。
*
* 现在按「这一句的完整文本」实际量一次宽度来定字号,所以:
* - 任何长度的句子都恰好占满可用宽度,不溢出不换行
* - 打字机逐字出现时不会重排(量的是完整句子,不是当前已打出的部分)
* - 字号只在句子切换时变一次,那一刻 h1 是空的,看不出跳动
*/
const fullText = phrases[phraseIndex].prefix + phrases[phraseIndex].highlight
const h1Ref = useRef<HTMLHeadingElement | null>(null)
const measureRef = useRef<HTMLSpanElement | null>(null)
const designRef = useRef<HTMLSpanElement | null>(null)
const [fontSize, setFontSize] = useState<string | undefined>(undefined)
const fitFontSize = useCallback(() => {
const h1 = h1Ref.current
const meas = measureRef.current
const probe = designRef.current
if (!h1 || !meas || !probe) return
const available = h1.parentElement?.clientWidth ?? 0
if (available <= 0) return
// 测量用的 span 固定按 100px 渲染,所以 宽度/100 就是「1 个字号单位占几 px」
const widthAt100 = meas.getBoundingClientRect().width
if (widthAt100 <= 0) return
// 探针带着和 h1 一样的响应式字号类,用它的计算值当上限,
// 这样断点逻辑只写在 class 里一处,不用在 JS 里硬编码
const design = Number.parseFloat(window.getComputedStyle(probe).fontSize)
if (!Number.isFinite(design) || design <= 0) return
// 光标也占宽度(ml-2 + w-[0.08em]),不扣掉的话右边会顶出去一点
const perEm = widthAt100 / 100 + CURSOR_EM
const fitted = Math.max(0, (available - CURSOR_GAP_PX) / perEm)
const size = Math.min(design, Math.max(HERO_MIN_FONT, fitted))
setFontSize(`${size.toFixed(1)}px`)
}, [])
useEffect(() => {
fitFontSize()
const parent = h1Ref.current?.parentElement
let observer: ResizeObserver | null = null
if (parent && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(fitFontSize)
observer.observe(parent)
}
// 网络字体加载完成后字宽会变,重新量一次
document.fonts?.ready.then(fitFontSize).catch(() => {})
return () => observer?.disconnect()
}, [fitFontSize, fullText])
return ( return (
<section ref={sectionRef} className="relative min-h-[100svh] flex items-center justify-center px-6 bg-surface-subtle"> <section ref={sectionRef} className="relative min-h-[100svh] flex items-center justify-center px-6 bg-surface-subtle">
<div className="max-w-4xl mx-auto text-center"> {/* 容器给到 max-w-5xl:15 字 × 64px = 954px,4xl(896px) 装不下 */}
<div <div className="max-w-5xl mx-auto text-center">
className={`transition-all duration-1000 ease-out ${ {/* 入场动画走 CSS 而不是 JS 控制的 opacity:
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" 原来 SSR 出来带 opacity-0,要等 React hydrate 才变可见,
}`} 慢网络下首屏是一片空白,LCP 也被拖后。 */}
> <div className="hero-enter">
<p className="text-[0.65rem] sm:text-xs tracking-[0.4em] text-muted-foreground/60 uppercase mb-8"> <p className="text-[0.65rem] sm:text-xs tracking-[0.4em] text-muted-foreground/60 uppercase mb-8">
{site.tagline} {site.tagline}
</p> </p>
{/* 光标是 CSS ::after 画的一个竖线字符,见 styles/global.css 的 .hero-typewriter */}
<h1 <h1
ref={h1Ref} className={`hero-typewriter font-sans ${HERO_SIZE_CLASSES} font-bold tracking-tight text-foreground leading-[1.15] mb-12 min-h-[1.2em] whitespace-nowrap`}
style={fontSize ? { fontSize } : undefined}
className={`font-sans ${HERO_SIZE_CLASSES} font-bold tracking-tight text-foreground leading-[1.1] mb-8 min-h-[1.2em] whitespace-nowrap transition-[font-size] duration-300 ease-out`}
> >
{renderText()} {renderText()}
<span
className="hero-cursor inline-block w-[0.08em] h-[0.9em] bg-foreground align-middle rounded-sm ml-2 -translate-y-1"
/>
</h1> </h1>
{/*
两个量尺寸用的隐藏元素,外层 h-0 + overflow-hidden 保证它们
不参与布局、也不会撑出横向滚动条:
- measureRef:按 100px 渲染当前这句完整文本,用来算实际字宽
- designRef :带着和 h1 相同的响应式字号类,用来读当前断点的设计字号
*/}
<div className="absolute inset-x-0 top-0 h-0 overflow-hidden" aria-hidden="true">
<span
ref={measureRef}
className="absolute left-0 top-0 whitespace-nowrap font-sans font-bold tracking-tight invisible"
style={{ fontSize: "100px" }}
>
{fullText}
</span>
<span
ref={designRef}
className={`absolute left-0 top-0 whitespace-nowrap font-sans font-bold tracking-tight invisible ${HERO_SIZE_CLASSES}`}
>
字
</span>
</div>
<blockquote className="max-w-xl mx-auto mb-10">
<p className="text-content-secondary text-base sm:text-lg leading-relaxed italic">
「我不知道我在想什么,直到我读到我写的东西。」
</p>
<footer className="mt-2 text-sm text-muted-foreground/70">
—— Flannery O'Connor
</footer>
</blockquote>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a <a
href="#home-main" href="#home-main"
+4 -4
View File
@@ -1,5 +1,5 @@
{ {
"updatedAt": "2026-09-15T10:00:22.188Z", "updatedAt": "2026-09-16T09:46:47.801Z",
"source": "weread", "source": "weread",
"books": [ "books": [
{ {
@@ -8,12 +8,12 @@
"author": "布尔加科夫", "author": "布尔加科夫",
"category": "文学-外国文学", "category": "文学-外国文学",
"cover": "https://wfqqreader-1252317822.image.myqcloud.com/cover/299/26678299/t6_26678299.jpg", "cover": "https://wfqqreader-1252317822.image.myqcloud.com/cover/299/26678299/t6_26678299.jpg",
"progress": 30, "progress": 31,
"finished": false, "finished": false,
"lastReadAt": "2026-09-13T15:08:26.000Z", "lastReadAt": "2026-09-15T16:12:26.000Z",
"startedAt": "2026-09-04T16:07:07.000Z", "startedAt": "2026-09-04T16:07:07.000Z",
"finishedAt": null, "finishedAt": null,
"readingSeconds": 4030, "readingSeconds": 4209,
"deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db" "deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db"
}, },
{ {
+51 -8
View File
@@ -745,18 +745,61 @@ a.card-github:hover .github-logo {
border-radius: 3px; border-radius: 3px;
} }
@keyframes hero-cursor-blink { /* 首屏入场。走 CSS 不用 JS 控制 opacity —— 否则 SSR 出来的 HTML 带
0%, opacity-0,要等 hydrate 才可见,慢网络下第一屏是白屏。 */
100% { @keyframes hero-enter {
opacity: 1; from {
opacity: 0;
transform: translateY(2rem);
} }
50% { to {
opacity: 0.35; opacity: 1;
transform: none;
} }
} }
.hero-cursor { .hero-enter {
animation: hero-cursor-blink 0.9s ease-in-out infinite; animation: hero-enter 900ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.hero-enter {
animation: none;
}
}
@keyframes hero-cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
/*
* 首屏打字机的光标。
*
* 用伪元素画一个竖线字符,而不是插一个 <span>:
* - 不需要额外 DOM,也不用在 JS 里算宽高
* - 字号一变它自己跟着缩放(原来是写死 px 再换算 em)
* - 不占额外宽度,避免把 15 个字顶出容器
* 做法取自 Firefly 的 TypewriterText。
*/
.hero-typewriter::after {
content: "|";
margin-left: 0.06em;
font-weight: 300;
opacity: 0.85;
animation: hero-cursor-blink 1.05s step-end infinite;
}
@media (prefers-reduced-motion: reduce) {
.hero-typewriter::after {
animation: none;
}
} }
/* ——— 偶得 (thoughts) view ——— */ /* ——— 偶得 (thoughts) view ——— */