使用 CSS clamp() 基于视口线性缩放字体大小

Avatar of Pedro Rodriguez
Pedro Rodriguez

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

过去曾尝试过使用多种方法(例如媒体查询和 CSS calc())来实现响应式排版。

在这里,我们将探索一种不同的方法,用于随着视口宽度的增加在最小和最大尺寸之间线性缩放文本,旨在使其在不同屏幕尺寸下的行为更可预测——这一切都只需一行 CSS 代码,这要归功于 clamp()

CSS 函数 clamp() 是一款强大的工具。它可用于各种用途,但在排版方面尤其出色。以下是它的工作原理。它接受三个值:

clamp(minimum, preferred, maximum);

它返回的值将是首选值,直到该首选值低于最小值(此时将返回最小值)或高于最大值(此时将返回最大值)。

在此示例中,首选值为 50%。在 400px 视口的左侧,50% 为 200px,小于使用的 300px 最小值。在右侧,1400px 视口的 50% 等于 700px,大于最小值且小于 800px 最大值,因此它等于 700px。

那么,假设您没有设置奇怪的值并将其设置为最小值和最大值之间,它是否总是首选值呢?好吧,您很可能会为首选值使用公式,例如

.banner {
  width: clamp(200px, 50% + 20px, 800px); /* Yes, you can do math inside clamp()! */
}

假设您希望在视口宽度为 360px 或以下时将元素的最小 font-size 设置为 1rem,并在视口宽度为 840px 或以上时将其最大值设置为 3.5rem。

换句话说

1rem   = 360px and below
Scaled = 361px - 839px
3.5rem = 840px and above

任何介于 361 和 839 像素之间的视口宽度都需要一个在 1 和 3.5rem 之间线性缩放的字体大小。使用 clamp() 这实际上非常容易!例如,在视口宽度为 600 像素时(介于 360 和 840 像素之间的一半),我们将获得 1 和 3.5rem 之间的确切中间值,即 2.25rem。

Line chart with the vertical axis measured in font size rem unites from 0 to 4, and the horizontal axis measuring viewport width from 0 to 1,060 pixels. There are four blue points on the grid with a blue line connecting them.

我们试图使用 clamp() 实现的目标称为线性插值:获取两个数据点之间的中间信息。

以下是执行此操作的四个步骤

步骤 1

选择您的最小和最大字体大小,以及您的最小和最大视口宽度。在我们的示例中,字体大小为 1rem 和 3.5rem,宽度为 360px 和 840px。

步骤 2

将宽度转换为 rem。由于大多数浏览器中 1rem 默认值为 16px(稍后详细介绍),因此我们将使用它。因此,现在最小和最大视口宽度将分别为 22.5rem 和 52.5rem。

步骤 3

在这里,我们将稍微偏向数学方面。当视口宽度和字体大小配对时,它们在 X 和 Y 坐标系上形成了两个点,而这些点构成了一条线。

A two-dimensional coordinate chart with two points and a red line intersecting them.
(22.5, 1)(52.5, 3.5)

我们需要这条线——或者更准确地说,我们需要它的斜率及其与 Y 轴的交点。以下是计算方法

slope = (maxFontSize - minFontSize) / (maxWidth - minWidth)
yAxisIntersection = -minWidth * slope + minFontSize

这给了我们 0.0833 的斜率值和 -0.875 的 Y 轴交点值。

步骤 4

现在我们构建 clamp() 函数。首选值的公式为

preferredValue = yAxisIntersection[rem] + (slope * 100)[vw]

因此函数最终如下所示

.header {
  font-size: clamp(1rem, -0.875rem + 8.333vw, 3.5rem);
}

您可以在以下演示中可视化结果

继续操作并尝试一下。如您所见,当视口宽度为 840px 时,字体大小停止增长,并在 360px 时停止缩小。介于两者之间的所有内容都以线性方式变化。

如果用户更改根字体大小会怎样?

您可能已经注意到这种方法的一个小缺陷:它只有在根字体大小与您认为的一致(在前面的示例中为 16px)且从不更改时才有效。

我们将宽度 360px 和 840px 转换为 rem 单位,方法是将它们除以 16,因为我们假设这是根字体大小。如果用户将其首选项设置为其他根字体大小(例如 18px 而不是默认的 16px),则该计算将出错,并且文本不会以我们期望的方式调整大小。

这里只有一种方法可以使用,那就是 (1) 在页面加载时在代码中进行必要的计算,(2) 侦听根字体大小的变化,以及 (3) 如果发生任何更改则重新计算所有内容。

以下是一个用于执行计算的有用 JavaScript 函数

// Takes the viewport widths in pixels and the font sizes in rem
function clampBuilder( minWidthPx, maxWidthPx, minFontSize, maxFontSize ) {
  const root = document.querySelector( "html" );
  const pixelsPerRem = Number( getComputedStyle( root ).fontSize.slice( 0,-2 ) );

  const minWidth = minWidthPx / pixelsPerRem;
  const maxWidth = maxWidthPx / pixelsPerRem;

  const slope = ( maxFontSize - minFontSize ) / ( maxWidth - minWidth );
  const yAxisIntersection = -minWidth * slope + minFontSize

  return `clamp( ${ minFontSize }rem, ${ yAxisIntersection }rem + ${ slope * 100 }vw, ${ maxFontSize }rem )`;
}

// clampBuilder( 360, 840, 1, 3.5 ) -> "clamp( 1rem, -0.875rem + 8.333vw, 3.5rem )"

我故意省略了如何将返回的字符串注入 CSS,因为根据您的需求以及您是否使用原生 CSS、CSS-in-JS 库或其他内容,有许多方法可以做到这一点。此外,没有针对字体大小更改的原生事件,因此我们必须手动检查。我们可以使用 setInterval 每秒检查一次,但这可能会带来性能损失。

这更多的是一个极端情况。很少有人会更改其浏览器的字体大小,更少的人会在访问您的网站时更改它。但是,如果您希望您的网站尽可能地具有响应性,那么这就是方法。

对于那些不介意这种极端情况的人

更新:自从本文首次发布以来,此处共享的资源已停止工作。如果您正在寻找计算器来帮助确定各种视口下的字体大小,请考虑使用 流体字体生成器,它使用了现代的流体排版技术。

如何避免文本重排

对排版尺寸进行如此细粒度的控制使我们能够做其他一些很酷的事情——例如阻止文本在不同的视口宽度下重排。

这是文本的正常行为。

它在某个视口宽度下具有多行……
……并换行以适应另一个宽度

但是现在,凭借我们所拥有的控制力,我们可以使文本保持相同的行数,始终在同一个单词处断开,无论我们使用什么视口宽度。

视口宽度 = 400px
视口宽度 = 740px

那么我们如何做到这一点呢?首先,字体大小和视口宽度之间的比率必须保持不变。在此示例中,我们从 320px 处的 1rem 更改到 960px 处的 3rem。

320 / 1 = 320
960 / 3 = 320

如果我们使用之前创建的 clampBuilder() 函数,则会变成

const text = document.querySelector( "p" );
text.style.fontSize = clampBuilder( 320, 960, 1, 3 );

它保持相同的宽度与字体比率。我们这样做的原因是,我们需要确保文本在每个宽度下都具有正确的尺寸,以便能够保持相同的行数。它仍然会在不同的宽度下重排,但这样做对于我们接下来要做的操作是必要的。

现在我们必须借助 CSS 字符 (ch) 单位,因为仅使字体大小正确是不够的。一个 ch 单位相当于元素字体中“0”字形的宽度。我们希望使文本主体与视口一样宽,不是通过设置 width: 100%,而是使用 width: Xch,其中 X 是水平填充视口所需的 ch 单位(或 0)数量。

为了找到X,我们必须将最小视口宽度 320px 除以元素在视口宽度为 320px 时的 ch 大小。在本例中,字体大小为 1rem。

别担心,这里有一段代码可以计算元素的 ch 大小

// Returns the width, in pixels, of the "0" glyph of an element at a desired font size
function calculateCh( element, fontSize ) {
  const zero = document.createElement( "span" );
  zero.innerText = "0";
  zero.style.position = "absolute";
  zero.style.fontSize = fontSize;

  element.appendChild( zero );
  const chPixels = zero.getBoundingClientRect().width;
  element.removeChild( zero );

  return chPixels;
}

现在我们可以继续设置文本的宽度了

function calculateCh( element, fontSize ) { ... }

const text = document.querySelector( "p" );
text.style.fontSize = clampBuilder( 320, 960, 1, 3 );
text.style.width = `${ 320 / calculateCh(text, "1rem" ) }ch`;
嗯,谁邀请来参加聚会,滚动条?

哇,等等。发生了一些不好的事情。有一个水平滚动条搞砸了事情!

当我们谈论 320px 时,我们指的是视口的宽度,包括垂直滚动条。因此,文本的宽度被设置为可见区域的宽度加上滚动条的宽度,这会导致它水平溢出。

那么为什么不使用一个不包含垂直滚动条宽度的度量呢?我们做不到,这是因为 CSS 的 vw 单位。请记住,我们在 clamp() 中使用 vw 来控制字体大小。你看,vw 包括垂直滚动条的宽度,这使得字体随着包括滚动条在内的视口宽度一起缩放。如果我们想避免任何重排,那么宽度必须与视口的任何宽度成比例,包括滚动条。

那么我们该怎么做呢?当我们这样做时

text.style.width = `${ 320 / calculateCh(text, "1rem") }ch`;

…我们可以通过将其乘以小于 1 的数字来缩小结果。0.9 可以解决问题。这意味着文本的宽度将是视口宽度的 90%,这将足以解决滚动条占用的一小部分空间。我们可以通过使用更小的数字(例如 0.6)来使其更窄。

function calculateCh( element, fontSize ) { ... }

const text = document.querySelector( "p" );
text.style.fontSize = clampBuilder( 20, 960, 1, 3 );
text.style.width = `${ 320 / calculateCh(text, "1rem" ) * 0.9 }ch`;
再见,滚动条!

您可能很想简单地从 320 中减去几个像素以忽略滚动条,如下所示

text.style.width = `${ ( 320 - 30 ) / calculateCh( text, "1rem" ) }ch`;

这样做的的问题是它会带来重排问题!这是因为从 320 中减去会破坏视口与字体的比例。

视口宽度 = 650px
视口宽度 = 670px

文本的宽度必须始终是视口宽度的百分比。另一件需要注意的事情是我们需要确保我们在每个使用该网站的设备上都加载相同的字体。这听起来很明显,不是吗?好吧,这里有一个可能导致文本错位的细节。执行诸如 font-family: sans-serif 之类的操作不能保证在每个浏览器中都使用相同的字体。sans-serif 将在 Windows 版 Chrome 上设置 Arial,但在 Android 版 Chrome 上设置 Roboto。此外,某些字体的几何形状可能会导致重排,即使您做对了所有事情。等宽字体往往会产生最佳效果。因此,始终确保您的字体准确无误。

在以下演示中查看此无重排示例

容器内的无重排文本

我们现在要做的就是在容器上应用字体大小和宽度,而不是直接在文本元素上应用。其中的文本只需要设置为 width: 100%。对于段落和标题,这不是必需的,因为它们无论如何都是块级元素,并且会自动填充容器的宽度。

在父容器中应用此方法的一个优点是,其子元素将自动响应和调整大小,而无需逐个设置其字体大小和宽度。此外,如果我们需要更改单个元素的字体大小而不影响其他元素,我们只需将其字体大小更改为任何 em 值,它将自然地相对于容器的字体大小。

无重排文本很挑剔,但它是一种微妙的效果,可以为设计带来一丝优雅!

总结

最后,我整理了一个小演示,展示了所有这些内容在现实场景中的外观。

在这个最终示例中,您还可以更改根字体大小,并且 clamp() 函数将自动重新计算,以便文本在任何情况下都具有正确的大小。

尽管本文的目标是将 clamp() 与字体大小一起使用,但相同的技术可用于接收长度单位的任何 CSS 属性。现在,我并不是说你应该在任何地方都使用它。很多时候,一个简单的 font-size: 1rem 就足够了。我只是想向您展示在需要时您可以拥有多少控制权。

就我个人而言,我认为 clamp() 是 CSS 中出现的最佳功能之一,我迫不及待地想看看随着它的普及,人们会想出哪些其他用法!