首屏打字机改为固定字号 + 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:
+30
-125
@@ -3,29 +3,33 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { site } from "@/data/site"
|
||||
|
||||
/**
|
||||
* 打字机文案。**只放长度接近的短句** —— 字号是固定的,靠"文案等长"来
|
||||
* 保证不溢出,而不是靠运行时测量(见下方 HERO_SIZE_CLASSES 的说明)。
|
||||
* 原来那四句是 14/11/4/4 字,长短差 3.5 倍,只能靠自适应字号兜底,
|
||||
* 结果是每换一句首屏最大的字就跳 1.5 倍。
|
||||
*/
|
||||
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_MIN_FONT = 20
|
||||
|
||||
/** 光标的占位:ml-2 的 8px + w-[0.08em],算字号时要一起扣掉 */
|
||||
const CURSOR_GAP_PX = 8
|
||||
const CURSOR_EM = 0.08
|
||||
const HERO_SIZE_CLASSES = "text-[clamp(1.125rem,5.6vw,4rem)]"
|
||||
|
||||
export function Hero() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [phraseIndex, setPhraseIndex] = useState(0)
|
||||
const [displayText, setDisplayText] = useState("")
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -35,10 +39,6 @@ export function Hero() {
|
||||
const [isActive, setIsActive] = useState(true)
|
||||
const sectionRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) setIsActive(false)
|
||||
@@ -110,7 +110,7 @@ export function Hero() {
|
||||
}, [displayText, isDeleting, phraseIndex, getTypeSpeed, getDeleteSpeed])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !isActive) return
|
||||
if (!isActive) return
|
||||
const timer = window.setTimeout(typeWriter, 50)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
@@ -119,7 +119,7 @@ export function Hero() {
|
||||
innerTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [mounted, isActive, typeWriter])
|
||||
}, [isActive, typeWriter])
|
||||
|
||||
const renderText = () => {
|
||||
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 (
|
||||
<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">
|
||||
<div
|
||||
className={`transition-all duration-1000 ease-out ${
|
||||
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
{/* 容器给到 max-w-5xl:15 字 × 64px = 954px,4xl(896px) 装不下 */}
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
{/* 入场动画走 CSS 而不是 JS 控制的 opacity:
|
||||
原来 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">
|
||||
{site.tagline}
|
||||
</p>
|
||||
|
||||
{/* 光标是 CSS ::after 画的一个竖线字符,见 styles/global.css 的 .hero-typewriter */}
|
||||
<h1
|
||||
ref={h1Ref}
|
||||
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`}
|
||||
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`}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/*
|
||||
两个量尺寸用的隐藏元素,外层 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">
|
||||
<a
|
||||
href="#home-main"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-09-15T10:00:22.188Z",
|
||||
"updatedAt": "2026-09-16T09:46:47.801Z",
|
||||
"source": "weread",
|
||||
"books": [
|
||||
{
|
||||
@@ -8,12 +8,12 @@
|
||||
"author": "布尔加科夫",
|
||||
"category": "文学-外国文学",
|
||||
"cover": "https://wfqqreader-1252317822.image.myqcloud.com/cover/299/26678299/t6_26678299.jpg",
|
||||
"progress": 30,
|
||||
"progress": 31,
|
||||
"finished": false,
|
||||
"lastReadAt": "2026-09-13T15:08:26.000Z",
|
||||
"lastReadAt": "2026-09-15T16:12:26.000Z",
|
||||
"startedAt": "2026-09-04T16:07:07.000Z",
|
||||
"finishedAt": null,
|
||||
"readingSeconds": 4030,
|
||||
"readingSeconds": 4209,
|
||||
"deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db"
|
||||
},
|
||||
{
|
||||
|
||||
+51
-8
@@ -745,18 +745,61 @@ a.card-github:hover .github-logo {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@keyframes hero-cursor-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
/* 首屏入场。走 CSS 不用 JS 控制 opacity —— 否则 SSR 出来的 HTML 带
|
||||
opacity-0,要等 hydrate 才可见,慢网络下第一屏是白屏。 */
|
||||
@keyframes hero-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2rem);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-cursor {
|
||||
animation: hero-cursor-blink 0.9s ease-in-out infinite;
|
||||
.hero-enter {
|
||||
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 ——— */
|
||||
|
||||
Reference in New Issue
Block a user