CSS 比较函数构建响应式UI
在 CSS 中说起函数,我想很多人首先想到是 calc()
数学运算函数,其实在 CSS 中有很多种不同类型的函数,有些“CSS函数”可以在CSS中用于动态计算,比如 calc()
函数,计数器函数 counter()
和 counters()
和 CSS比较函数 min()
、max()
和 clamp()
。尤其是 calc()
、min()
、max()
和 clamp()
这几个函数,在现代Web开发中所起的作用越来越大,使用的场景越来越多,带来的灵活性和扩展性越来越强。那么在这篇文章中,我们主要和大家一起来探讨 CSS 比较函数在实际开发中能用在哪些地方?又是如何帮助前端构建响应式UI?感兴趣的同学请继续往下阅读。
你需要准备的知识
在这篇文章中,我们并不会详细介绍这些函数(calc()
、min()
、max()
和clamp()
)的基础理论,但如果你想更顺畅的了解它们在实战的功能以及不阻碍你往下阅读和实际使用,你需要具备一些有关于它们的基础知识。如果你从未接触这些领域,建议你先花一点时间阅读下面这几篇文章:
除此之外,我们接下来的内容会用到很多关于 CSS 自定义属性相关的知识,你可以阅读《图解CSS:CSS自定义属性》一文,或者点击这里了解更多关于 CSS 自定义属性。
要是你不想花时间去深入了解上面提到的这些 CSS 特性,那就请花几分钟时间快速了解一下。
先来看最早在 CSS 中可用于计算的 calc()
函数。
calc()
将允许你在用于 CSS 尺寸属性的值进行基本的数学运算,比如给width
的值做 加(+
) 、 减(-
) 、 乘(×
) 和 除(÷
) 四则运算。并且可以在单位之间做插值计算,比如不同单位之间的混合计算,如calc(100% - 20px)
,运算式中的%
和px
单位,客户端会自己进行插件计算。
calc()
最大的好处是允许你避免在 CSS 硬编码一系列神奇的数字或使用 JavaScript 来计算所需的值。特别是在 CSS 自定义属性的使用中,calc()
的身影随处可见。比如下面这个示例,使用 calc()
将一个数值变成带有%
的值:
:root {
--h: 180;
--s: 50;
--l: 50;
}
.icon__container {
color: hsl(var(--h) calc(var(--s) * 1%) calc(var(--l) * 1%));
}
.icon__container--like {
--h: 232;
}
而比较函数 min()
、max()
和 clamp()
和 calc()
类似,除了可以接受 加(+
) 、 减(-
) 、 乘(×
) 和 除(÷
) 四则运算之外,还可以接受一个值列表参数,浏览器则会从这个值列表中确定使用哪个值。
min(<value-list>)
:从逗号分隔的表达式列表中选择最小值(最小负数),相当于 使用min()
设置最大值max(<value-list>)
:从逗号分隔的表达式列表中选择最大值(最大正数),与min()
刚好相反,相当于 使用max()
设置最小值clamp(<min>, <ideal>, <max>)
:根据设定的理想值(<ideal>
),将值限定在上限(<max>
)与下限(<min>
)之间,相当于 使用了min()
和max()
函数, 即clamp(<min>, <ideal>, max)
等于max(<min>, min(<ideal>, <max>))
我分别给 min()
、max()
和 clamp()
录制了一个视频,可以从视频中窥探出它们带来的作用:
视频录自于 min() Function
视频录自于 max() Function
视频录自于 clamp() Function
有关于 min()
、max()
和 clamp()
更详细的介绍可以阅读:
- 聊聊
min()
,max()
和clamp()
函数 min()
、max()
和clamp()
:今天要使用的三个逻辑 CSS 函数min()
,max()
, andclamp()
CSS Functions
虽然 min()
、 max()
和 clamp()
看上去和 calc()
相似,都可以在允许使用 <length>
、<frequency>
、<angle>
、<time>
、<percentage>
、<number>
或 <integer>
值类型的属性上使用,并且进行基础的数学运算。但它们有着一个本质的区别:
min()
、max()
和clamp()
函数中的参数,计算的值取决于上下文。
简单地说:
calc()
执行基本的数学运算,具有在单位类型之间的插值能力min()
允许以包含元素的响应环境的方式设置最大允许值的界限max()
允许以包含元素的响应环境的方式设置最小允许值的界限clamp()
允许在可接受的数值范围内设置界限
这些函数给开发者提供更为强大的动态控制能力,也将允许你使用它们创建更具动态性和响应性的网站或应用程序。接下来,我们通过一些实例来体验他们强大的能力。
案例
在《下一代响应式Web设计:组件驱动式Web设计》一文中深度的探讨了 CSS 容器查询特性可以让我们在微观上改变组件样式。更为响应式Web设计带来新的理念。另外,时至今日,CSS 已经发展到有很多新特性(《2022 年的 CSS》)可以让开发者更容易构建动态响应的Web了。甚至这些新特性允许我们只用一行代码就可以实现以往需要多个代码块才能实现的效果。
为此,我们就从布局的案例开始。
布局响应
现代Web布局中,常常基于 CSS 的 Flexbox 或 Grid 来布局,但不管是使用哪种布局技术,都离不开对元素的尺寸设置(会涉及到 CSS 盒模型相关属性)和 值单位的运用等。也就是说,我们在布局中总是少不了对尺寸进行计算。在没有这些 CSS 函数之前,我们需要使用 JavaScript 来动态计算,但有了这些函数特性,让你布局更为灵活。
我们先从两列布局开始。
假设你有一个两列布局,侧边栏宽度是 220px
和 自适应的主内容区域:
关键代码如下:
:root {
--aside-w: 220px;
--gap: 20px;
}
body {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
aside {
flex-basis: var(--aside-w);
flex-shrink: 0;
}
main {
flex:1 1 calc(100% - var(--aside-w) - var(--gap));
}
示例中在 flex-basis
中使用 calc()
计算赋值,即使不赋值 main
也会因 flex-shrink
和 flex-grow
做自适应匹配。我们显式设置该值是因为,在Flexbox布局中设置 Flex 项目的尺寸时有一个隐式的公式存在:
content
➜width
➜flex-basis
。
意思就是,如果未显式指定flex-basis
的值,那么flex-basis
将回退到width
属性;如果未显式指定width
,那么flex-basis
将回到基于Flex项目内容的计算宽度。 有关于这方面更详细的介绍,你可能要花一些时间阅读:
效果看上去符合我们的预期,但事实未必。如果你尝试着把浏览器视窗尺寸调小,你会发现在窄屏的时候,布局效果差强人意:
我们希望,在窄屏的时候效果如下:
如果你对 Flexbox 较为熟悉的话,你可以已经想到了,可以在 aside
元素上显式设置 flex-grow
值为 1
,可以让 Flex 项目根据 Flexbox 容器剩余空间进行扩展。
aside {
flex: 1 0 var(--aside-w);
}
main {
flex: 1 1 calc(100% - var(--aside-w) - var(--gap));
}
我们可以在这个基础上做得更好,可以给 main
设置一个最佳的值,比如:
main {
flex: 1 1 calc(100% - var(--aside-w) - var(--gap));
min-width: min(100%, 18ch);
}
在 min-width
属性上使用 min()
设置了下界面,根据上下文环境计算,当18ch
长度值小于100%
(父容器宽度的100%
,),min()
会取18ch
,反之则会取100%
:
我们先来看未设置 min-width
的效果:
加上min-width:min(100%, 18ch)
之后的效果:
CSS中取值为
%
时,它的计算依赖于上下文环境,即不同属性取%
值时,计算时参照物(相对属性)不同。如果你对这方面知识感兴趣的话,可以移步阅读《CSS中百分比单位计算方式》一文。另外,ch
单位是一个近似等宽的一个单位,它基于字体0
字形宽度计算,它会随字体和字号变化,相关的介绍可以查阅《图解CSS:CSS 的值和单位》一文。
如此一来,你的布局不会那么容易被打破,具有较强的动态性和响应性,而且还不需要依赖任何的 JavaScript 脚本和 CSS媒体查询(或其他的条件查询)。这也就是在《如何编写防御式的 CSS》所提到的,构建具有防御式的CSS。
在 aside
(侧边栏)的 flex-basis
同样可以使用 min()
或 max()
函数对其宽度做一个尺寸的界限设置,比如:
aside {
flex-basis: max(30vw, var(--aside-w));
}
当视窗的宽度达到 1068px
左右的时候,30vw
的值才大于 --aside-w
,即只有视窗宽度大于 1068px
,max()
才会取 30vw
,否则会取 --aside-w
的值(320px
)。
你可能已经想到了,在
flex-basis
上也可以使用clamp()
函数来设置值,答案确定的,但我们把clamp()
用于布局中的使用放到后面来介绍。
在现代Web的布局中除了 Flexbox 之外还有 CSS Grid 布局(CSS Grid用于布局的灵活度是你不可想象的,在2022年,你已经很有必要开始学习 CSS Grid 相关的知识了)。如果你阅读过《网格轨道尺寸的设置》和《网格中的可用函数》的话,你已经知道了,我们可以使用 min()
、max()
、clamp()
以及 calc()
等函数来设置网格轨道的尺寸(就是给 grid-template-columns
或grid-template-rows
设置列或行的尺寸)。接下来看,一起来看看它们在网格布局中的使用。
:root {
--aside-w: 320px;
--gap: 20px;
}
.container {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--gap);
}
aside {
width: max(20vw, var(--aside-w));
}
可以直接将max()
用于grid-template-columns
:
.container {
display: grid;
grid-template-columns: max(20vw, var(--aside-w)) 1fr;
gap: var(--gap);
}
@media screen and (max-width: 760px) {
.container {
grid-template-columns: auto;
grid-template-rows: auto 1fr;
}
aside {
order: 2;
}
}
该示例借用了 CSS 媒体查询特性,浏览器视窗宽度小于 760px
时改变网格轨道的设置:
min()
和max()
函数除了用于flex-basis
和网格轨道之外,还可以用于布局的包装器中。 @shadeed9 在 《Styling Layout Wrappers In CSS》一文中详细介绍了布局包装器样式的设计。文章中提到的场景也是我们常用的方式:
以往我们实现上图这样的布局,常用的方式是:
.wrapper {
max-width: 1170px;
width: 100%;
margin: 0 auto;
}
或者:
.wrapper {
max-width: 1170px;
width: 100%;
padding: 0 16px;
margin: 0 auto;
}
这样的布局场景,我们可以直接使用 min()
来替代max-width
:
.wrapper {
width: min(1170px, 100% - 32px);
padding: 0 16px;
margin: 0 auto;
}
这段代码的意思是,当浏览器视窗宽度大于或等于1170px
时,.wrapper
宽度是 1170px
;一旦视窗宽度小于1170px
时,.wrapper
的宽度是 100% - 32px
(这里的32px
是设置了padding-left
和padding-right
的和)。
尝试改变视窗宽度,你可以看到下面这样的效果:
用于 .wrapper
上的min()
可以换成 clamp()
函数,设置一个更理想的宽度,比如:
:root {
--max-viewport-size: 100vw;
--ideal-viewport-size: 1170px;
--min-viewport-size: 320px;
--aside-size: 320px;
--gap: 20px;
--padding: 1rem;
}
.wrapper {
width: clamp(var(--min-viewport-size), var(--ideal-viewport-size), var(--max-viewport-size));
padding: 0 var(--padding);
margin: 0 auto;
}
这个示例展示了如何使用 clamp()
来设置一个理想想的宽度(注意,也可以用于其他属性)。不过 Temani Afif 在他的文章《Responsive Layouts, Fewer Media Queries》中介绍了clamp()
用例,阐述了如何使用clamp()
创建理想的列。
"理想列"这个词是我根据“理想宽度”提出来的!
在《现代Web技术让我们离容器查询更近一步》 一文中介绍过,Flexbox 和 Grid 布局让我们在构建响应式Web设计时,在一些特殊场景中可以不依赖任何 CSS 媒体特性就能实现,甚至可以让你的布局离 CSS 容器查询更近一步。比如下面这样的布局:
:root {
--item-size: 400px;
--gap: 1rem;
}
.flex {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
.flex li {
flex: 1 1 var(--item-size);
}
.grid {
display: grid;
gap: var(--gap);
grid-template-columns: repeat(auto-fit, minmax(var(--item-size), 1fr));
}
分别使用 flex
和flex-wrap
(方案一)或 auto-fit
和 minmax()
(方案二),能让卡片随容器尺寸做出响应:
注意,示例中的
minmax()
和min()
以及max()
不是同一个东西,minmax()
接受两个参数值,将划定一个<min>
到<max>
范围,它返回的是这个范围中的动态值。它只能用于 CSS Grid 中的grid-template-columns
或grid-template-rows
两个属性中。不过,min()
、max()
、clamp()
和calc()
表达式都可以是他的一个值。 有关于这方面更详细的介绍,可以阅读《图解CSS: Grid布局(Part5)》一文。
不过这两种方案都有一定的缺陷:
在 CSS 中有多种方式可以让这种响应变得更好,比如 CSS 容器查询 和 关系型选择器等。不过这里来简单介绍 Temani Afif 的文章《Responsive Layouts, Fewer Media Queries》中的方案,即使用 clamp()
创建理想列:
上图的意思是,可以随着浏览器视窗宽度来改变排列的列数:
- 视窗宽度小于或等于
W3
时,呈一列显示 - 视窗宽度在
W3 ~ W2
范围内,呈P
列显示 - 视窗宽度在
W2 ~ W1
范围内,呈M
列显示 - 视窗宽度大于或等于
W1
时,呈N
列显示
具体怎么使用呢?先留着!回到上面的示例中,我们期望的是每于显示四个项目(即四列),假设这个列数是 N
;并且列与列之间有一个间距,比如gap
,那么可以计算出来每个项目的宽度:
列宽= 容器宽度的 100% ÷ 列数 - (列数 - 1) × 列间距 = 100% ÷ N - (N - 1) × gap
这样就可以计算出Flex项目或Grid项目的初始宽度:
:root {
--n: 4; /* 期望的列数 */
--gap: 20px; /* 期望的列间距 */
--initial-item-size: calc(100% / var(--n) - (var(--n) - 1) * var(--gap));
}
.flex--item {
flex: var(--initial-item-size);
}
.grid {
grid-template-columns: repeat(auto-fit, minmax(var(--initial-item-size), 1fr))
}
在此基础上,我们可以把 min()
或 max()
函数加入进来,给Flex或Grid项目设置一个理想的宽度(你期望他最大或最小的宽度)。比如:
:root {
--n: 4;
--gap: 20px;
--ideal-item-size: 400px;
--initial-item-size: max(var(--ideal-item-size), 100% / var(--n) - (var(--n) - 1) * var(--gap))
}
上面这个公式我们考虑了列间距--gap
,其实也可以将去去除,可以将计算公式进一步优化:
// 优化前
width = 100% / N - (N - 1) * gap
// 优化后
width = 100% / (N + 1) + 0.1%
优化后的公式,其逻辑是 “告诉浏览器,每个Flex或Grid项目的宽度等于 100% / (N + 1)
”,因此每行都会有 N + 1
个项目。每个项目宽度增加 0.1%
是CSS的一个小魔法,就是让第 N + 1
个项目断行。所以,最后呈现的是每行 N
个项目排列(也就是 N
列排列):
:root {
--ideal-item-size: 400px;
--n: 4;
--gap: 20px;
--responsive-item-size: max(
var(--ideal-item-size),
100% / (var(--n) + 1) + 0.1%
);
}
.flex {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
.flex li {
flex: var(--responsive-item-size);
}
.grid {
display: grid;
gap: var(--gap);
grid-template-columns: repeat(
auto-fit,
minmax(var(--responsive-item-size), 1fr)
);
}
就这个示例而言,你会发现,每个项目最小宽度会是 400px
(--ideal-item-size
):
- 当容器有足够空间(刚好和理想的设计一样的尺寸)容纳的时候他刚好会是四列(
4 x 400px
)加上三个列间距(--gap
) - 当容器有足够空间(比
4 x 400px + 3 x 20px
还要大)容纳时,也将呈现四列,但每个项目的宽度会扩展,扩展值是均分容器剩余空间 - 当容器无足够空间(比
4 x 400px + 3 x 20px
要小)容纳时,每行呈现的列数将会小于4
,且每个项目的宽度会扩展,扩展值是均分容器剩余空间
不过,这个方案运用于 CSS Grid 的轨道尺寸设置时,存在一个较大的缺陷:当网格容器小于--ideal-item-size
时,网格项目会溢出网格容器:
要解决这个现象,可以使用 clamp()
替代前面公式中的 max()
,即:
// max() 函数公式
width = max(400px,100% / (N + 1) + 0.1%)
// 使用clamp()函数替换
width = clamp(100% / (N + 1), 400px, 100%)
其所起作用是:
- 当容器宽度较大时,
100% / (N + 1) + 0.1%
的值(或可能)会大于400px
,相当于clamp(<min>, <ideal>, <max>)
函数中的<min>
值大于<ideal>
值,clamp()
函数此时会取<min>
值(即,100% / (N + 1) + 0.1%
)。这样就保持了对每行最大项目数(N
)的控制 - 当容器宽度较小时,
100%
的值(或可能)会小于400px
,相当于clamp(<min>, <ideal>, <max>)
函数中的<max>
值小于<ideal>
值,clamp()
函数此时会取<max>
值(即,100%
)。这样就保证了项目(主要是网格项目)不会溢出容器 - 当容器宽度存在一个最理想状态下,即
400px
介于100 / (N + 1) + 0.1%
和100%
之间,相当于clamp(<min>, <ideal>, <max>)
函数中的<ideal>
在<min>
和<max>
之间,clamp()
函数此时会取<ideal>
值(即400px
)
将上面的示例稍作修改:
:root {
--ideal-item-size: 400px;
--n: 4;
--gap: 20px;
--responsive-item-size: clamp(
100% / (var(--n) + 1) + 0.1%,
var(--ideal-item-size),
100%
);
}
现在离目标更近了,只不过到目前为止,还无法控制项目何时断行。我们并无法知道断行在什么时候发生,因为它受制于多个因素,比如基础宽度(--ideal-item-size
)、间距(--gap
)、容器宽度等。为了控制这一点,需要对clamp()
函数中的参数值进一步改进:
// Before
:root {
--responsive-item-size: clamp(
100% / (var(--n) + 1) + 0.1%,
var(--ideal-item-size),
100%
);
}
// After
:root {
--responsive-item-size: clamp(
100% / (var(--n) + 1) + 0.1%, // => <min>
(var(--ideal-item-size) - 100vw) * 1000, // => <ideal>
100% // => <max>
)
}
简单解释一下这样做的意义:
- 当容器宽度(最大是浏览器视窗宽度,即
100vw
)大于--ideal-item-size
时,var(--ideal-item-size) - 100vw
会产生一个负值,会小于100% / (var(--n) + 1) + 0.1%
,也就确保每行保持--n
个项目 - 当容器宽度(最大是浏览器视窗宽度,即
100vw
)小于--ideal-item-size
时,var(--ideal-item-size) - 100vw
是一个正值,并乘以一个大值(这里是1000
),它会大于100%
,也就确保了项目的宽度将是100%
到此为止,使用了 N
列到 1
列的响应。有了这个基础,要从 N
列到 M
列响应也就不是难事了。只需将 M
列参数引入到 clamp()
函数中:
width = clamp( 100% / (N + 1) + 0.1%, (400px - 100vw) * 1000, 100% / (M + 1) + 0.1%)
使用这个公式来替换前面的示例:
:root {
--n: 4;
--m: 2;
--ideal-item-size: 400px;
--responsive-item-size: clamp(
100% / (var(--n) + 1) + 0.1%,
(var(--ideal-item-size) - 100vw) * 1000,
100% / (var(--m) + 1) + 0.1%
)
}
尝试着改变上面示例浏览器窗口大小,你会发现,它始终是从 N
列到 M
列变化,即使容器再小,也会是 M
列,除非将 M
的值设置为 1
:
继续往前!
如果期望每行列数从 N ▶ M ▶ 1
进行响应。要实现这样的响应,从代码上来看会比前面的复杂,需要引入断点值,比如:
- 大于
W1
断点时每行保持N
个项目 - 大于
W2
断点时每行保持M
个项目 - 小于
W2
断点时每行保持1
个项目
除了引入W1
和 W2
两个断点之外,还需要使用 clamp()
嵌套 clamp()
:
clamp(
clamp(
100%/(N + 1) + 0.1%,
(W1 - 100vw)*1000,
100%/(M + 1) + 0.1%
),
(W2 - 100vw)*1000,
100%
)
有了前面的基础,要理解这个就容易多了。你可以从里面往外面去理解,先看里面的 clamp()
:
- 当屏幕宽度小于断点
W1
时,会保持每行M
个项目呈现 - 当屏幕宽度大于断点
W1
时,会保持每行N
个项目呈现
再看外面那个 clamp()
:
- 当屏幕宽度小于断点
W2
时,会保持每行1
个项目呈现 - 当屏幕宽度大于断点
W2
时,将会按里面的clamp()
返回值来做决定
如果使用这个公式来构建 N ▶ M ▶ 1
列响应的示例,其代码看起来如下:
:root {
--W1: 800px; // 第一个断点
--W2: 400px; // 第二个断点
--n: 4; // 第一个断点对应的每行项目数
--m: 2; // 第二个断点对应的每行项目数
--responsive-item-size: clamp(
clamp(
100% / (var(--n) + 1) + 0.1%,
(var(--W1) - 100vw) * 1000,
100% / (var(--m) + 1) + 0.1%
),
(var(--W2) - 100vw) * 1000,
100%
)
}
同样的原理,要实现更多的列响应,只需要新增断点和clamp()
的嵌套,比如前面我们说的 N ▶ M ▶ P ▶ 1
的列响应:
:root {
--W1: 1200px;
--n: 8;
--W2: 992px;
--m: 6;
--W3: 768px;
--p: 4;
--responsive-item-size: clamp(
clamp(
clamp(
100% / (var(--n) + 1) + 0.1%,
(var(--W1) - 100vw) * 1000,
100% / (var(--m) + 1) + 0.1%
),
(var(--W2) - 100vw) * 1000,
100% / (var(--p) + 1) + 0.1%
),
(var(--W3) - 100vw) * 1000,
100%
)
}
依此类推,就可以在不同断点下控制不同列数的呈现。
盒模型尺寸响应
上一节花了很长的篇幅介绍了 min()
、max()
和 clamp()
在布局上的响应,大部分围绕着 Flexbox 的 flex-basis
和 Grid 中的网格轨道(grid-template-columns
)展开。不过,在CSS中决完元素尺寸的属性有很多,比如与盒模型有关的属性。也就是说,我们可以将这几个函数运用于这些属性上,让它们能根据语境来动态响应。
有关于 CSS 的盒模型更多的介绍可以阅读:
响应元素大小
在任何时候,如果你想响应的调整一个元素的大小,min()
函数都是一个不错的选择。比如前面提到的,给.wrapper
容器一个最大值:
.wrapper {
width: min(var(--max-wrapper-size, 1170px), 100% - var(--wrapper-padding, 1rem) * 2);
padding-inline: var(--wrapper-padding, 1rem);
margin-inline: auto;
}
如果你正在构建一个阅读类的应用的话,可以使用clamp()
函数给文本段落设置一个更适合阅读的理想宽度。正如 Robert Bringhurst 在 《The Elements of Typographic Style Applied to the Web》文中所说: “对于有衬线字体的单列页面,一般认为一行 45 ~ 75
个字符的长度是比较理想的”。为了确保文本块不少于 45
个字符,也不超过 75
个字符,你可以使用 clamp()
和 ch
单位给文本块设置尺寸,比如给段落p
设置宽度:
p {
width: clamp(45ch, 100%, 75ch);
}
更为有意思的是 Temani Afif 的《Responsive Layouts, Fewer Media Queries》一个关于元素显式隐藏切换的示例。使用 clamp()
函数可以让一个元素根据屏幕尺寸来显示还是隐藏(以往要实现这样的效果是需要使用媒体查询或JavaScript):
.toggle--visibility {
max-width: clamp(0px,(100vw - 500px) * 1000,100%);
max-height:clamp(0px,(100vw - 500px) * 1000,1000px);
margin:clamp(0px,(100vw - 500px) * 1000,10px);
overflow:hidden;
}
基于屏幕宽度(100vw
),把元素的max-width
(最大宽度)和 max-height
(最大宽度)要么限制在0px
(此时元素不可见),要么限制在100%
(此时元素可见,并且永远不会超过全宽)。注意,示例中max-height
并没有设置 100%
,而是取了一个较大的因定值1000px
,主是在因为max-height
取百分比,会致使用例失效(如果其父容器未显式设置height
值,max-height
取百分比值会无效)。有关于百分比更详细的介绍,可以移步阅读《CSS中百分比单位计算方式》一文。
注意示例中绿色块在小屏幕上是如何消失的:
特别需要注意的是,在
clamp()
、min()
和max()
函数中的值为0
时,需要带上单位,不然浏览器会将其视为无效的语法。所以示例中clamp()
中的<min>
值为0
时,显式的带上了单位,即0px
。个人建议带上熟悉的单位!
出于Web可访问性考虑,在构建一些可接收指针事件的区域(例如,鼠标点击或触摸点击)时,其最小尺寸设置为 44px
比较理想,这个标准也是 WCAG成功标准(SC)2.5.5定义的。
在这些情况下,我们可以使用max()
,将 44px
作为 max()
中的一个值,这样一来,最小的时候,该元素的尺寸也能达到 WCAG 成功标准(SC) 2.5.5。
.button--icon {
width: max(44px, 2rem);
aspect-ratio: 1;
}
如果你对 Web 可访问性相关的内容感兴趣的话,可以点击这里查阅!
示例中使用了 aspect-ratio
来设置图标按钮的宽高比,虽然aspect-ratio
已得到了主流浏览器的支持,但如果你想给aspect-ratio
提供一个渐进增强的效果,那么 max()
是一个首先方案。比如 SmolCSS 演示的卡片组件中就有aspect-ratio
:
.smol-card-component {
--img-ratio: 3/2;
}
.smol-card-component > img {
aspect-ratio: var(--img-ratio);
object-fit: cover;
width: 100%;
}
我们可以像下面这要使用max()
给aspect-ratio
做降级处理:
img {
/* 定义高度的宽高比降级方案 */
height: max(18vh, 12rem);
object-fit: cover;
width: 100%;
}
/* 支持 aspect-ratio */
@supports (aspect-ratio: 1) {
img {
aspect-ratio: var(--img-ratio);
height: auto;
}
}
响应间距
到目前为止,前面的内容所谈到的是如何使用 min()
、max()
或 clamp()
函数让元素占用的空间和大小具有动态响应能力。接下来,我们来看看这几个函数又是如何让元素之间和周围间距具有动态响应能力。这将帮助我们能更好的设计响应UI。
在 CSS 中设置元素之间间距一般都会使用 margin
或 gap
(在 Flexbox 或 Grid 格式化上下文中可以使用了),元素与容器边缘的间距一般使用 padding
来设置。当然,你也可以使用《图解CSS:CSS逻辑属性》文中提到 CSS 逻辑属性来替代我们熟悉的物理属性 margin
和 padding
。
margin
、padding
和 gap
他们有着不同的作用这是众所周之的,在这里并不是介绍这几个属性的作用和使用,而是来聊聊这几个函数是如何让它们具有动态响应能力。
先从内距 padding
开始!
大家是否有过设计师给你提这样的需求:“希望在宽屏时内距大一点,窄屏时内距小一点”。以往要实现这样的效果通常是使用 CSS 媒体查询在不同断点下给元素设置不同的padding
值来实现。对于现在而言,我们使用 min()
或 max()
就可以很轻易的实现。比如:
.wrapper {
padding-inline: max(2rem, 50vw - var(--contentWidth) / 2)
}
甚至设计师还会跟你说:“内距随着容器宽度增长和缩小,而且永远不会小于 1rem
,也不会大于3rem
”。面对这样的需求,clamp()
会显得更具魅力,我们可以像下面这样使用 clamp()
给元素设置 padding
值:
.element {
padding-inline: clamp(1rem, 3%, 3rem);
}
与媒体查询相比,这里最重要的好处是,这里定义的padding
是相对于元素的,当元素在页面上有更多的空间时,它就会变大,反之就会变小,并且这个变化始终界于1rem ~ 3rem
之间。如此一来,你就可以像下面这样来定义padding
:
:root {
--padding-sm: clamp(1rem, 3%, 1.5rem);
--padding-md: clamp(1.5rem, 6%, 3rem);
--padding-lg: clamp(3rem, 12%, 6rem);
}
这样做,除了能满足设计需求之外,对于 Web 可访问性也是有益的。WCAG Success Criterion 1.4.10 - Reflow 有过这样一段描述:
Reflow is the term for supporting desktop zoom up to
400%
. On a1280px
wide resolution at400%
, the viewport content is equivalent to320
CSS pixels wide. The intent of a user with this setting is to trigger content to reflow into a single column for ease of reading.
这个规则定义了 Reflow(回流)的标准。Reflow是支持桌面缩放的一个技术术语,其最高可达 400%
。在 1280px
宽分辨率为 400%
的情况下,视口内容相当于 320
CSS像素宽。用户使用此设置的目的是触发内容回流到单个列中,以便于阅读。换句话说,该标准定义了对浏览器放大到 400%
的期望。在这一点上,屏幕的计算宽度被假定为 320px
左右。
结合前面的内容,我们可以像下面这样设置样式,将能给用户一个较好的阅读体验:
p {
width: clamp(45ch, 100%, 75ch);
padding-inline: clamp(1rem, 3%, 1.5rem);
}
注意,padding
值采用百分比单位的话,它的计算是相对于元素的内联尺寸(Inline Size)计算的,这对于 clamp()
特别友好,可以使用百分比作为一个相对于元素的动态值。除此之外,还可以使用视窗单位,比如 vh
、vmax
之类,它们特别适用于相当于视窗大小来动态调整值的场景。
上图来自于 @shadeed9 的 《min()
, max()
, and clamp()
CSS Functions》一文。这样设置可以让 .hero
的上下内距随视窗高度来动态调整:
.hero {
padding-block: clamp(2rem, 10vmax, 10rem);
}
接下来是 margin
。为了简化问题,这里拿垂直方向的外间距(margin
主要是用来设置元素与元素之间在内联方向 Inline Axis 和块轴方向 Block Axis 方向的间距)来举例。比如:
.element + .element {
margin-block-start: min(4rem, 8vh)
}
.block-flow {
margin-block-start: min(4rem, 8vh)
}
给 min()
函数提供了 4rem
和 8vh
两个值,相对而言,4rem
是一个静态值(除非 html
根元素的 font-size
值是动态的,因为 rem
单位是相对于 html
元素的 font-size
计算的),8vh
是一个动态值(vh
相对于浏览器视窗的高度计算,即 1vh
相当于浏览器视窗高度的 1%
)。你也可以使用新增的 svh
、lvh
或 dvh
,它们都是相对于视窗高度来计算,只是计算方式不同,而且这几个单位是新增加的视窗单位。
似乎偏题了,我们回到margin
的动态值设置中来。min(4rem, 8vh)
这个规则带来的效果如下:
正如上图所示:
- 图 ① 是大屏幕下的效果
- 图 ② 是小屏幕下的效果
- 图 ③ 是大屏幕下放大的效果
图 ① 和 图 ② 效果相比,并没有太大的差异,在移动端可能为用户节省了大约 13px
的空间,但 图 ③ 放大(缩放屏幕)带来的差异就很大了,即 8vh
带来的差异很明显。
注意,也可以使用
max()
来替代min()
。
和 padding
的使用相似,也可以通过 CSS 自定义属生给 margin
使用 min()
、max()
或 clamp()
函数定义值:
:root {
--block-flow-sm: min(2rem, 4vh);
--block-flow-md: min(4rem, 8vh);
--block-flow-lg: min(8rem, 16vh);
}
甚至还可以使用一些新特性,让你的CSS变得更为强大,比如:
:is(body, .block-flow ) > * + * {
margin-block-start: var(--block-flow, var(--block-flow-md, min(4rem, 8vh)))
}
使用 :is()
来做选择器的判断,比如上面的代码,body
的子元素(除第一个子元素)或 在使用了 .block-flow
容器的中的子元素(除其第一个子元素)的 margin-block-start
会使用 --block-flow
的值,如果该值自定义属性未定义,则会使用 --block-flow-md
,相通的道理,如果--block-flow-md
也未赋值,则会采用 min(4rem, 8vh)
:
:is()
是 CSS 中新增的伪类选择器,它和:not()
、:has()
以及:where()
被称为 CSS 的逻辑组合选择器。如果你对这几个选择器感兴趣的话,可以移步阅读《初探CSS 选择器Level 4》、《CSS 选择器:is()
和:where()
与:has()
有什么功能》 和 《CSS 的父选择器:has()
》!
最后一个就是 gap
属性。它可以像 margin
属性一样,用来设置元素与元素之间的间距,但它们两者之间有着明显的差异,下图体现了它们之间的差异:
到目前为止,gap
属性只能用于 多列布局、Flexbox布局 和 Grid布局 的容器上。
毫无悬念,min()
、max()
和 clamp()
都可以用于 gap
属性上。 这里我们只来看 clamp()
的使用:
.flex {
display: flex;
gap: clamp(1.5rem, 6vmax, 3rem);
}
.grid {
display: grid;
gap: clamp(1.5rem, 6vmax, 3rem);
}
示例中使用的是 vmax
单位而没有像前面示例那样采用 %
单位,这主要是因为 %
用于 gap
属性时的计算是相对于 gap
方向来计算,这就可能造成列和行之间的间距不相等。而采用 vmax
就可以避免这样的现象。
你也可以像前面的那样,为你的设计定义一套统一的间距:
:root {
--layout-gap-sm: clamp(1rem, 3vmax, 1.5rem);
--layout-gap-md: clamp(1.5rem, 6vmax, 3rem);
--layout-gap-lg: clamp(3rem, 8vmax, 4rem);
}
边框和圆角半径的响应
在一些设计方案中,有些元素的边框(border-width
)和圆角半径(border-radius
)很大,但希望在移动端上更小一些。比如下图这个设计:
桌面端(宽屏)中卡片的圆角 border-radius
是 8px
,移动端(窄屏)是 0
。以往你可能是这样来写:
.card {
border-radius: 0;
}
@media (min-width: 700px) {
.card {
border-radius: 8px;
}
}
如果用 JavaScript 来描述的话,就会是这样:
if (cardWidth >= viewportWidth) {
radius = 0;
} else {
radius = 8px;
}
不过,我想说的是,现在在CSS中要实现上面这种效果已不再需要依赖媒体查询或JavaScript了,还有更多的选择,比如你已经知道的 CSS 容器查询:
.card--container {
container-type: inline-size;
}
.card {
border-radius: 0;
}
@container (width > 700px) {
.card {
border-radius: 8px;
}
}
或者是 CSS 的条件规则,即 @when
和 @else
:
@when container(width >= 100vw) {
.card {
border-radius: 0;
}
}
@else {
.card {
border-radius: 8px;
}
}
Stefan Judis 在 《Conditional border-radius and three future CSS features》把这个称为 “条件半径”,即根据上下文环境来改变属性的值。
如果你急于当下就在项目中使用“条件半径”,也可以。可以像 Temani Afif 的《Responsive Layouts, Fewer Media Queries》文章中介绍的那样,将其运用于 border-radius
上。
:root {
--w: 760px;
--max-radius: 8px;
--min-radius: 0px; /* 这里的单位不能省略 */
--radius: (100vw - var(--w));
--responsive-radius: clamp(
var(--min-radius),
var(--radius) * 1000,
var(--max-radius)
);
}
div {
border-radius: var(--responsive-radius, 0);
}
调整视窗大小,效果如下:
@shadeed9 在他的 《Conditional Border Radius In CSS》介绍了另一种方案来实现条件半径。将 min()
和 max()
结合在一起:
.card {
border-radius: max(0px, min(8px, calc((375px - 4px - 365px) * 9999)));
/* will result to */
border-radius: max(0px, min(8px, 59994px));
}
注意,在 min()
、max()
和clamp()
函数内部使用计算时,calc()
可以省略:
.box {
--min-radius: 0px;
--max-radius: 8px;
--ideal-radius: 4px;
border-radius: max(
var(--min-radius),
min(var(--max-radius), (100vw - var(--ideal-radius) - 100%) * 9999)
);
}
该解决方案的灵感来自Heydon Pickering的文章《Flexbox Holy Albatross》。它被Facebook的Naman Goel改编为适用于border-radius
。其实他也适用于其他需要设置长度的属性上。
另外,clamp(<min>, <ideal>, <max>)
等同于 max(<min>, min(<ideal>, <max>))
,那么,@shadeed9 的文章 《Conditional Border Radius In CSS》所介绍的方案就可以使用 Temani Afif 的文章《Responsive Layouts, Fewer Media Queries》介绍的方案替代。
如果你对“有条件半径”相关的介绍感兴趣,可以移步阅读:
- Conditional Border Radius In CSS
- The Flexbox Holy Albatross
- Conditional border-radius and three future CSS features
- Responsive Layouts, Fewer Media Queries
你可能已经想到了,这里提到的技术也适用于其他需要动态响应的UI属性上,比如边框,阴影等。
流畅的排版
为了实现流畅的排版,Mike Riethmeuller 推广了一种技术。该技术使用 calc()
函数来设置最小字体大小、最大字体大小,并允许从最小值放大至最大值。社区也把这种技术称为 CSS 锁(CSS Locks)
如果用CSS来描述的话会像下面这样:
/**
* 1. minf: 最小font-size
* 2. maxf: 最大font-size
* 3. minw: 视窗最小宽度
* 4. maxw: 视窗最大宽度
*/
font-size: calc([minf]px + ([maxf] - [minf]) * ( (100vw - [minw]px) / ([maxw] - [minw]) ));
@media only screen and (max-width: [minw]px) {
font-size: [minf]px;
};
@media only screen and (min-width: [maxw]px) {
font-size: [maxf]px;
};
比如:
@media screen and (min-width: 25em){
html {
font-size: calc( 16px + (24 - 16) * (100vw - 400px) / (800 - 400) );
}
}
@media screen and (min-width: 25em){
html {
font-size: calc( 16px + (24 - 16) * (100vw - 400px) / (800 - 400) );
}
}
@media screen and (min-width: 50em){
html {
font-size: calc( 16px + (24 - 16) * (100vw - 400px) / (800 - 400) );
}
}
CSS 的 clamp()
函数的出现,我们可以将上面的公式变得更简单,比如:
html {
font-size: clamp(1rem, 1vw + 0.75rem, 1.5rem);
}
开发者可以直接使用 Adrian Bece 提供的在线工具 Modern Fluid Typography Editor:
今年年初的时候, Adrian Bece 专门用了一篇文章《Modern Fluid Typography Using CSS Clamp》介绍了这个公式是如何计算的。如果你感兴趣的话,可以详细阅读这篇文章。
使用 clamp()
(或max()
)能让我们轻易实现文本大小随视窗宽度(或容器宽度)动态响应(进行缩放),直到达到设定的界限(最大值和最小值),从而实现流畅的排版效果。只不过,该技术对于 Web 可访问性是有一定伤害性的。在 WCAG 1.4.4 调整文本大小 (AA) 下,利用 max()
或 clamp()
限制文本大小可能导致 WCAG 失败,因为用户可能无法将文本缩放到其原始大小的 200%
。因此,在实际运用当中,还需要开发者根据具体的需求做出正确的选择。
完美缩放 UI 界面
Andy Bell 在他的文章《Consistent, Fluidly Scaling Type and Spacing》文章中提出:
一致、流畅地缩放字体和间距
也就是说,我们在构建 UI 界面时,特别是构建一个响应式UI界面时,我们应该将前面所介绍的内容结合在一起。因为我们知道了,在现代 Web 开发中,使用 min()
、max()
、calc()
、clamp()
以及 CSS 的相对单位(比如,rem
、em
、%
、vw
、vh
等),尤其是 clamp()
,我们可以CSS属性的值随着断点变化来动态响应。
如果不想把事情复杂化,我们可以简单地理解,使用CSS的一些现代技术,比如 CSS的自定义属性,CSS的计算函数,CSS相对单位等,让 CSS 相关的UI属性的值具备动态响应能力:
- 容器大小,
width
、height
、inline-size
、block-size
等 - 边框大小,
border-width
等 - 阴影大小,
box-shadow
、text-shadow
等 - 排版属性,
font-size
,line-height
等
我们把这些放在一起,就可以实现一个完美缩放(或者说一致、流畅地缩放)的 UI 界面。比如下面这个 Demo:
该技术实现的UI界面,其缩放的效果要比现在一些主流的适配方案,比如 Rem适配 和 VW适配 要完美地多:
是不是很完美。如果你想更深入的了解如何构建一个完美缩放的 UI 界面感兴趣的话,可以阅读早前整理的一篇文章《如何构建一个完美缩放的UI界面》。
内容切换
上面的示例中,我们有一个是元素隐藏和显示切换的效果。利用同样的原理,我们可以实现内容动态切换的效果。比如 @张鑫旭 老师的 《纯CSS实现未读消息超过100自动显示为99+》一文,就是在 font-size
上使用 min()
完成内容切换:
实现上图关键代码:
li span {
font-size: min(0.75rem, 10000px - var(--num) * 100px);
}
li span::before {
content: "99+";
font-size: min(0.75rem, var(--num) * 100px - 9900px);
}
li[style="--num: 0;"] span {
opacity: 0;
}
相关原理请阅读 @张鑫旭 老师的 《纯CSS实现未读消息超过100自动显示为99+》一文。
其实在 Temani Afif 的《Responsive Layouts, Fewer Media Queries》文章中也有一个类似的示例,只不过使用的是 clamp()
实现的:
实现原理和 @张鑫旭 老师文章中介绍的原理相似,都是改变font-size
的值,不同的是,Temani Afif 的示例 可以让我们根据屏幕的断点来对内容进行切换:
<!-- HTML -->
<button>
Contact Us
<svg> </svg>
</button>
/* CSS */
.contact {
--w: 600px;
--c: (100vw - var(--w));
padding: clamp(10px, var(--c) * -1000, 20px);
font-size: clamp(0px, var(--c) * 1000, 30px);
border-radius: clamp(10px, var(--c) * -1000, 100px);
}
.contact svg {
width: 1em;
height: 1em;
font-size: clamp(0px, var(--c) * -1000, 30px);
}
注意,示例中 svg
的 width
和 height
设置为 1em
很关键,可以让 svg
图标的大小相对于 font-size
进行计算。你可以尝试改变浏览器视窗的大小,当视窗宽度小于 600px
时,文本按钮会自动切换成一个图标按钮:
Temani Afif 的示例效果更为复杂一点,他把定位的位置信息也用 clamp()
函数来赋值。这里不再阐述,感兴趣的可以查看示例中的代码。
响应背景尺寸和渐变位置
min()
、max()
和 clamp()
还有一个特殊的用处,可以对背景图片或渐变位置做一些界限限制。
或许你要提供一个背景颜色和图像分层效果,与其使用 cover
(background-size
的一个值),让图像填满整个容器空间,还不如为图像的增长上设置上限。比如下面这个示例,在background-size
中使用min()
函数,确保背景图片不超过 600px
,同时通过设置 100%
允许图像与元素一起向下响应。也就是说,它将增长到 600px
,当元素的宽度小于 600px
时,它将调整自己的大小来匹配元素的宽度:
.element {
background: #1f1b1c url(https://picsum.photos/800/800) no-repeat center;
background-size: min(600px, 100%);
}
反过来,也可以使用 clamp()
根据断点来设置background-size
的值。比如:
div {
--w: 760px;
--min-size: 600px;
--max-size: 100%;
background-size: clamp(var(--min-size), var(--w) * 1000, var(--max-size)) auto;
}
Temani Afif 的《Responsive Layouts, Fewer Media Queries》文章还介绍了使用 max()
函数,在不同的背景上设置不同的background-size
,来实现在不同断点下显示不同的背景颜色(有条件显示背景颜色):
body {
background:
linear-gradient(to right, purple 0, purple 100%) 0 0 / max(0px, 320px - 100%) 1px,
linear-gradient(to right, blue 0, blue 100%) 0 0 / max(0px, 760px - 100%) 1px,
linear-gradient(to right, green 0, green 100%) 0 0 / max(0px, 1024px - 100%) 1px;
background-color: red;
}
尝试改变视窗大小,你会看到 red
、purple
、blue
、green
几个背景颜色会随着视窗宽度变化而动态响应:
再来看用于渐变上的效果。
@shadeed9 的 《min()
, max()
, and clamp()
CSS Functions》一文中有一个示例,演示了使用比较函数前后渐变在不同终端上的效果差异。
.element {
background: linear-gradient(135deg, #2c3e50, #2c3e50 60%, #3498db);
}
上面这样的渐变,你平时可能没少写,只不很少有同学会留意,上面的渐变效果在不同屏幕(或不同尺寸的元素上)的效果是有一定差异的:
如果想让渐变的效果在桌面端和移动端上看上去基本一致,一般会使用媒体查询来调整渐变颜色的位置:
@media (max-width: 700px) {
.element {
background: linear-gradient(135deg, #2c3e50, #2c3e50 25%, #3498db);
}
}
现在,我们可以使用 min()
这样的函数让事情变得更简单:
.element {
background: linear-gradient(135deg, #2c3e50, #2c3e50 min(20vw, 60%), #3498db);
}
另外,平时在处理图片上文字效果时,为了增强文本可阅读性,你可能会在文本和图片之间增加一层渐变效果。那么这个时候,使用 max()
函数控制渐变中透明颜色位置就会变得有意义地多:
.element {
background: linear-gradient(to top, #000 0, transparent max(20%, 20vw));
}
其他
我想上面这些示例可以给你提供使用 calc()
、min()
、max()
和 clamp()
几个数学函数的思路,只要你掌握了它们的基本原理,并且发挥你的创意,你肯定能创造出更多有意义的案例。正如 Temani Afif 的《Responsive Layouts, Fewer Media Queries》文章所展示的那些优秀而且实用的案例。这里就不一一展示了,如果你感兴趣或者有更好的创意,请一起分享。
小结
就在前段时间,我在 《下一代响应式Web设计:组件驱动式Web设计》一文中分享了下一代响应式Web开发范式。我们可以基于媒体查询构建宏观的样式,基于容器查询构建微观上的样式。如果你在未来的开发中,将 Flexbox、Grid 和这些数学函数,相对单位等等结合起来,可以做的事情会更多。
简单地说,结合这些特性,你可以在不依赖,甚至只需要依赖一点点 CSS 媒体查询就可以构建出具有响应式的 UI,比如响应尺寸、位置、颜色等等。当然,这些特性并不是说让你从此开始抛弃CSS媒体查询,而是应该合理使用这些特性,去优化和减少你的代码量,并且让你的代码更具防御性。
换句话说,这些优秀的 CSS 特性可以让前端开发者在开发 UI 界面时变得更容易,同时又具备超强的能力来控制设计行为。要是在以前,你是不敢想象的,也是CSS从未拥有过的。也只能说,CSS越来越强了,你将能使用CSS做更多有意义的事情。
参考资料
- Practical Uses of CSS Math Functions: calc, clamp, min, max
- Contextual Spacing For Intrinsic Web Design
- Responsive Layouts, Fewer Media Queries
- Modern Fluid Typography Using CSS Clamp
min()
,max()
, andclamp()
CSS Functions- 图解CSS: CSS中的函数
- CSS中的动态计算
- CSS3的
calc()
使用 - 聊聊
min()
,max()
和clamp()
函数 - 纯CSS实现未读消息超过100自动显示为99+