ByteNoteByteNote
极简信息流界面实战:负空间、滚动淡入与黑白灰
字

字节笔记本

2026年10月7日 · 约 9 分钟读完

极简信息流界面实战:负空间、滚动淡入与黑白灰

API中转
¥120

信息流几乎是所有内容产品的门面:新闻客户端的首页、博客的文章列表、开源社区的动作页,剥掉外壳后都是同一个东西——一列内容卡片加一个滚动容器。很多团队做这个界面的第一反应是上框架、装组件库,但如果目标只是「简约大气、有呼吸感」,原生 HTML、CSS 和 JavaScript 完全够用,几百行代码就能把界面设计的本质问题暴露得干干净净。它同时也是练习设计决策的最好题目:内容不同、场景不同,布局就没有标准答案。本文以一个真实迭代过的案例为线索,把这条路完整走一遍。

呼吸感从哪里来

「呼吸感」听起来玄,拆开看其实是三件事。

负空间。卡片之间、卡片与页面边缘的留白要慷慨。案例里容器限定 800px 居中,卡片间距与内边距都是 20px,标题下方留出 40px。数字本身不重要,重要的是间距成为一个系统,而不是随手填的值。留白也不是浪费:它给每条内容划定了边界,读者扫视时视线才有停顿的位置;塞满一屏的信息流,读起来像噪音。

克制的动效。内容进入视口时淡入并上浮 20px,过渡 0.5 秒。动效只出现在内容「到达」的那一刻,其余时间页面完全静止——这是「呼吸」与「抖动」的区别。

收敛的色彩。初期版本白卡灰底、一个强调色;后期干脆收敛成黑白灰,靠字重、字号与位置来分层。顺带一提,页面主标题用了细字重加大字距的排版处理——当颜色被拿掉之后,这类排版细节就接管了原本属于色彩的对比功能。

淡入动画的核心是「默认隐藏、可见时加类」:

css
.card {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity .5s ease, transform .5s ease;
}
.card.visible { opacity: 1; transform: translateY(0); }

布局的三次演进

这个案例最有价值的部分,是同一个需求被推倒重来了三次,每一次都是信息密度与视觉重量的重新权衡。

信息流界面的三次布局演进:卡片流、网格与编号列表

第一版是单列卡片流,标题、摘要加时间,适合逐条阅读的场景。第二版换成 CSS Grid 网格,一行放下更多标题:

css
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

auto-fill 加 minmax 的组合不需要任何媒体查询:容器每多出 250px 就自动多出一列,是响应式网格最省事的表达。

第三版改成一行一个的编号列表:每个条目左侧一条 4px 深色竖线,前面是 padStart 补零的大号浅灰序号,悬停时整行右移 10px、序号由浅灰变黑。去掉图片与圆角之后,信息密度反而最高,风格也更「编辑化」。黑白灰方案里还有个讨巧的细节:彩色图片默认以 filter: grayscale(100%) 灰度示人,悬停时恢复彩色——页面平时是纯粹的黑白灰,交互瞬间又给了反馈,克制与生气兼得。

三种布局各有适用场景:卡片流适合摘要较长、需要沉浸阅读的资讯类产品;网格适合图片主导、以「扫一眼挑内容」为主的门户或作品集;编号列表则适合密集更新的要闻与榜单。选哪种不取决于审美偏好,而取决于用户来到这个页面想干什么。

三次改版共享同一套骨架:一个数据数组作为唯一数据源,一个 createCard 函数按字段拼装 DOM,一个循环把卡片挂进容器。布局怎么变只改 CSS,渲染逻辑一行不动——这个「数据与视图分离」的最小实现,恰恰是后来所有前端框架想帮你解决的问题。

移动优先与详情页

目标设备收窄为手机后,几处调整是标配:容器降到 600px;字体栈换成 -apple-system, BlinkMacSystemFont, 'Segoe UI' 这样的系统字体,省一次字体请求,渲染也清晰;图片统一 object-fit: cover,不同比例的素材裁切填充不变形;间距整体收紧,点击区域放大到手指能稳定命中的尺寸。另外别忘了 viewport 的 meta 标签——没有它,移动浏览器会按约 980px 的桌面宽度渲染再整体缩小,所谓移动优化根本无从谈起。

详情页用了另一个经典手法:标题通过绝对定位叠在头图底部,用一层 linear-gradient(to top, rgba(0,0,0,.7), transparent) 渐变压住,保证任何图片上的白字都可读——浅色天空图最容易翻车,渐变要压够深;返回按钮 position: fixed 常驻左上角,不随内容滚走。正文区行高 1.6、按小节组织标题,长文阅读的舒适度基本就靠行高和段距撑起来。列表与详情之间用最朴素的方式连接:detail.html?id=1,详情页解析 URL 参数后渲染对应内容。原型阶段不必引入路由库,一个查询参数就够了。

踩坑与更优解

把这套原型当教学示例没有问题,但照搬到生产之前有几个坑必须填。

滚动淡入的更优解:用 IntersectionObserver 替换 scroll 监听

滚动监听该换成 IntersectionObserver。 原型的做法是在 scroll 事件里对每张卡片调 getBoundingClientRect:一是滚动时每帧都在查询布局,容易掉帧;二是判断条件要求卡片完全进入视口才触发,一张比视口还高的长卡片永远不会淡入。标准做法是交给观察器:

js
const io = new IntersectionObserver((entries) => {
  entries.forEach((e) => {
    if (e.isIntersecting) {
      e.target.classList.add('visible');
      io.unobserve(e.target);
    }
  });
}, { threshold: 0.15, rootMargin: '0px 0px -40px' });
document.querySelectorAll('.card').forEach((c) => io.observe(c));

交叉状态由浏览器在合成侧计算,不阻塞主线程;threshold 控制露出多少比例才触发;unobserve 保证动画只播一次,滚回去也不会重播。它的兼容性如今已覆盖所有主流浏览器,真要照顾老环境,官方也提供了 polyfill,不必再回退到手写滚动计算。

innerHTML 拼数据有 XSS 风险。 原型用模板字符串把标题直接塞进 innerHTML,数据是自己写死的数组时相安无事,一旦换成服务端返回,一段 <img onerror> 就能执行任意脚本。动态文本应该走 textContent 或 createElement,把「数据」与「结构」分开。

图片要懒加载并占位。 给 img 加 loading="lazy",并用 aspect-ratio 或 width、height 属性预留空间,否则图片加载完成后页面会跳动,累积布局偏移(CLS)直接超标。

尊重系统的减弱动态偏好。 把所有 transition 包进 @media (prefers-reduced-motion: no-preference),对晕动症用户是基本的礼貌。

首屏卡片别等动画。 「进入视口才显示」的逻辑同样作用于首屏内容,如果脚本加载慢,用户会先看到一片空白。合理做法是初始化后立即执行一轮判定,让已在视口内的卡片直接显形——案例原型在 load 事件里先调了一次可见性检查,方向是对的,只是别忘了这一步。

写在最后

这个案例走完「卡片流、网格、编号列表、移动端加详情页」四轮迭代,依赖的只有浏览器原生能力。它的价值不在代码本身,而在每次迭代逼你回答的问题:多一列还是少一列?图先出还是字先出?彩色还是灰度?组件库会给你默认答案,原生实现逼你自己回答。而且这四轮迭代没有一轮是推倒重来——改的只是样式表和数据字段,页面骨架始终成立;好的界面像好的代码,是演化出来的,不是一次画出来的。下次动手做新界面之前,不妨先花半小时用纯 HTML 写个原型——你会发现大部分「需要框架」的场景,其实只是还没想清楚。

相关文章

分享: