CSS 中的条件圆角技巧
CSS 的 border-radius
技术已经是非常成熟的技术了,在现代 Web 开发的过程中,实现圆角的效果都是使用 border-radius
来实现。使用 border-radius
来给一个盒子添加圆角效果已经是最为常见,最简单的技术了,但前段时间在 Twitter 上看到 @Ahmad Shadeed 发的一条推特信息说:“Facebook 应用在border-radius
上使用了 CSS 的比较函数(比如 min()
和max()
)来实现按条件给元素设置圆角效果”。这一说法并得到了 Facebook 的工程师 @Frank Yan的确认:
Facebook 的工程师使用了一种被称为 “Fab Four” 技术,可以让圆角根据一定的条件来设置不同的值。不知道你是否对该技术感到好奇呢?CSS 是如何有条件地将border-radius
设置不同的值。如果您感兴趣的话,请继续往下阅读。
Fab Four 技术
在开始聊有条件设置 border-radius
的技术方案之前,我们先简单地说一下 Fab Four 技术。毕竟@Frank Yan在他的Twitter中提到了“Fab Four”技术。那么该技术是指什么呢?简单地说:
Fab Four 技术指的是使用 CSS 的
calc()
、min-width
、max-width
和width
设置元素尺寸的一种技术。
事实上,它也是:
Fab Four 技术可以通过使用各种 CSS 函数来实现,比如
min()
、max()
、calc()
和clamp()
,以计算是否应该应用特定的 CSS 规则,同时将其与容器的元素尺寸而不是设备视口尺寸进行比较。
换句话说,你要了解 Fab Four 技术,那么你得具备下面所列的技术:
不过,我们今天主要要聊的是条件圆角,即 根据一定的条件,设置不同的圆角值!只不过,我们接下来要聊的不是仅使用 Fab Four 技术实现有条件的圆角值,而是聊聊,可能会使用的一些CSS技术。
条件圆角的使用场景
要聊条件圆角,就得从实际的使用场景开始聊。我想大家在平时的开发过程中,会碰到以下这样的场景。比如说,一个卡片组件,在默认情况之下它的值是 12px
,但当卡片占用整个屏幕(卡片和视窗大小一样),希望卡片的半径为0
:
即使在同一视窗尺寸之下,也有不同的圆角的使用场景,比如在卡片不同排列情景之下:
针对上面这样的场景,假如你来实现,会采用或想到哪些方案呢?
解决方案
接下来,我们来看看具体的解决方案。
媒体查询
针对第一种现象,首先想到的解决方案会是 CSS 媒体查询。
来看一个简单的示例:
.card,
.card__media {
border-radius: 0;
}
@media (min-width: 768px) {
.card {
border-radius: 24px;
}
.card__media img {
border-radius: 10px 10px 0 0;
}
}
拖动浏览器视窗宽度,你可以看到下面的效果:
在某些情况之下,CSS 媒体还是有所缺陷或者说限制。如果出于某些原因,我们想在视窗宽度小于450px
时卡片要有一个24px
的圆角(border-radius
),这个时候可能就需要添加额外的类名,比如:
@media (max-width: 450px) {
.card--rounded {
border-radius: 24px;
}
}
但对于图这样的场景(比如说都在移动端设备):
使用媒体查询估计就没什么意义了。针对上图这样的场景,最常见的解决方案就是添加额外的类名,比如:
/* 1 x 1 */
.card-1-1 {
border-radius: 24px;
}
/* 1 x 2 */
.card-1-2 {
border-radius: 18px;
}
/* 1 x 3 */
.card-1-3 {
border-radius: 10px;
}
容器查询
虽然说媒体查询在某些情况之下,可以实现有条件(根据视窗宽度)给卡片设置不同的圆角半径。不过,在接触过 CSS的容器查询 特性之后,可以让卡片组件根据其父容器(或祖先元素)的尺寸给卡片组件设置不同的圆角半径:
.card__container {
container: inline-size;
}
.card {
border-radius: 10px;
}
@container (min-width: 323px) {
.card {
border-radius: 18px;
}
.card__media img {
border-radius: 4px 4px 0 0;
}
}
@container (min-width: 713px) {
.card {
border-radius: 24px;
}
.card__media img {
border-radius: 10px 10px 0 0;
}
}
拖动示例中右下角的滑块,你可以看到面这样的效果:
CSS 容器查询应该是最佳的一个解决方案,因为它真正的做到了根据卡片组件容器的宽度来调整圆角半径值,换句话说,真正做到了有条件的设置圆角半径。但到目前为止,虽然CSS容器查询特性已进入到 FPWD 阶段,但离生产环境中使用还是需要一段时间:
如果你迫切想在生产环境中使用 CSS 容器查询特性的话,你可以引入 Container Query Polyfill ,这是一个体积大约 1.6KB 的脚本文件。它将在客户端转译 CSS 代码,并使用 ResizeObserver 和 MutationObserver 实现容器查询功能!
有关于 CSS 容器查询更多的介绍,可以阅读:
CSS锁技术:锁定一个范围值
曾经在《给CSS加把锁》和《如何构建一个完美缩放的UI界面》提到的技术用到这里。使用这种技术,可以给元素设置一个范围值的圆角半径:
它的计算公式如下:
border-radius: calc([minR]px + ([maxR] - [minR]) * ( (100vw - [minw]px) / ([maxw] - [minw]) ));
上面的calc()
计算涵盖了:
minR
:最小圆角半径maxR
:最大圆角半径minw
:视窗最小宽度(断点1)maxw
:视窗最大宽度(断点2)
比如下面这个示例:
.card {
--min-border-radius: 10;
--max-border-radius: 24;
--min-viewport-width: 320;
--max-viewport-width: 1024;
font-size: calc(
var(--min-border-radius) * 1px +
(var(--max-border-radius) - var(--min-border-radius)) *
(
(100vw - var(--min-viewport-width) * 1px) /
(var(--max-viewport-width) - var(--min-viewport-width))
)
);
border-radius: calc(
var(--min-border-radius) * 1px +
(var(--max-border-radius) - var(--min-border-radius)) *
(
(100vw - var(--min-viewport-width) * 1px) /
(var(--max-viewport-width) - var(--min-viewport-width))
)
);
}
改变视窗大小,你将看到下面的效果:
注意,如果将这个技术用于
font-size
上,还可以使用 @AdrianBeceDev 的 Modern fluid typography editor 工具,使用clamp()
函数来实现。如font-size: clamp(1.5rem, 2vw + 1rem, 2.313rem);
其实,我们可以使用clamp()
函数用于描述UI的其他CSS属性(描述长度的属性),比如《如何构建一个完美缩放的UI界面》一文中介绍的技术:
:root {
/* 理想视窗宽度,就是设计稿宽度 */
--ideal-viewport-width: 750;
/* 当前视窗宽度 100vw */
--current-viewport-width: 100vw;
/* 最小视窗宽度 */
--min-viewport-width: 320px;
/* 最大视窗宽度 */
--max-viewport-width: 1920px;
/**
* clamp() 接受三个参数值,MIN、VAL 和 MAX,即 clamp(MIN, VAL, MAX)
* MIN:最小值,对应的是最小视窗宽度,即 --min-viewport-width
* VAL:首选值,对应的是100vw,即 --current-viewport-width
* MAX:最大值,对应的是最大视窗宽度,即 --max-viewport-width
**/
--clamped-viewport-width: clamp( var(--min-viewport-width), var(--current-viewport-width), var(--max-viewport-width) )
}
.card {
--ideal-border-radius: 38; /* 750px 设计中圆角值 */
border-radius: calc( var(--ideal-border-radius) * var(--clamped-viewport-width) / var(--ideal-viewport-width) );
}
.card__media {
--ideal-border-radius: 24; /* 750px 设计中圆角值 */
border-radius: calc( var(--ideal-border-radius) * var(--clamped-viewport-width) / var(--ideal-viewport-width) );
}
.card__media img {
--ideal-border-radius: 24; /* 750px 设计中圆角值 */
border-radius: calc( var(--ideal-border-radius) * var(--clamped-viewport-width) / var(--ideal-viewport-width) );
}
改变视窗大小,效果如下:
Fab Four
就前面几种技术方案而言,CSS 容器查询是较为合适或者说完美的一种解决方案。而 CSS 锁(即 clamp()
和 calc()
)方案是一个适中的方案,他可以设置一个最小的圆角半径,一个最大的圆角半径,也就是说,圆角半径在最小和最大之间。但要真正实现两个值之间的切换,还是有缺陷的。比如:
if (cardWidth >= viewportWidth) {
radius = 0;
} else {
radius = 24px;
}
上面代码描述的意思是:
如果组件(比如卡片组件
<Card>
)的宽度大于或等于浏览器视窗宽度,则组件的圆角半径等于零;反之是一个确定的圆角半径,比如24px
。
为了在 CSS 中实现这一逻辑,我们需要使用 CSS 的比较函数在两个值之间进行比较。
如果你从未接触过 CSS 的比较函数,建议你花点时间先阅读《聊聊min(),max()和clamp()函数》一文。
该解决方案的灵感来自 @Heydon Pckering的《The Flexbox Holy Albatross》一文。前几天我在《现代Web技术让我们离容器查询更近一步》一文中也引用了该技术。不同的是,来自 Facebook 的 @Naman Goel 把这种技术运用到 border-radius
上,并且将其称为 Fab Four 技术。
Fab Four 技术可以通过使用各种CSS函数的比较函数来实现,比如min()
、max()
和clamp()
,以计算是否应该应用特定的CSS规则,同时将其与容器元素的尺寸而不是设备视窗进行比较。
.card {
border-radius: max(
0px,
min(24px, ((100vw - 12px - 100%) * 9999))
)
}
这段代码的意思是:
- 有一个
max(MIN,MAX)
函数,在0px
(也就是max()
函数中的MIN
值)与min(MIN,MAX)
(也就是max()
函数中的MAX
)的计算值之间进行比较,并且选择较大的值,即MAX
min(MIN,MAX)
函数和max(MIN,MAX)
相似,只不过取出较小的值,即MIN
值。这个示例中就是24px
(也就是min()
函数中的MIN
)和(100vw - 12px - 100%) * 9999
的计算值,这个计算值可能是一个非常大的正数或负数(也就是min()
中的MAX
值)之间进行比较(100vw - 12px - 100%) * 9999
数学表达式中的9999
是一个很大的数字,可以迫使表达式min(24px, ((100vw - 12px - 100%) * 9999))
计算出来的值是0px
或24px
注意,比较函数
min()
、max()
和clamp()
函数和calc()
函数相似,具备简单的数学四则运算(加、减、乘和除的运算)。
上面的数学运算中,最有意思的是 100%
这个数值。它可以根据两种不同的情况有所不同:
- 它可以等于其包含元素宽度的
100%
- 也可以等于
100vw
,即卡片组件占用整个视口宽度(比如,在移动端上)
注意,CSS中百分比是个复杂的单位,它被运用于CSS不同属性时,其值的计算方式有所不同,其中计算的奥秘请移步阅读《CSS中百分比单位计算方式》一文。
在整个数学运算表达式中,最为关键的是 ((100vw - 12px - 100%) * 9999)
计算值。正如@Temani Afif 在Twitter上回复的帖子所述:
假设视窗宽度是368px
,卡片容器的宽度是360px
。那么套到上面的数学运算中就是:
计处出来的值是-39996px
。此时,min(24px, ((100vw - 12px - 100%) * 9999))
就变成了min(24px, -39996px)
,整个表达式就是:max(0, min(24px, -399996px))
,该表达式会先对min()
函数的值进行比较,取出较小值-39996px
,再传给max()
函数进行比较,最终border-radius
的值为0
。
即:
.card {
border-radius: 0;
}
按这个原理,将其运用于一个实际示例中:
.card {
border-radius: max(0, min(24px, (100vw - 12px - 100%) * 9999))
}
正如上图所示,定义了两种情形:
- 在窄屏下,视窗宽度和卡片组件容器的宽度都是
375px
; - 在宽屏下,视窗宽度是
1344px
,卡片组件容器的宽度是864px
像前文所介绍的一样,将相应值套进公式中,先看窄屏下:
计算出来的值为0
,即:
.card {
border-radius: 0
}
将宽屏的值套进公式中:
计算出来的值为24px
,即:
.card {
border-radius: 24px;
}
我们在实际使用的时候,只需要像下面这样:
.card {
--radius: max(0px, min(24px, (100vw - 12px - 100%) * 9999));
border-radius: var(--radius);
}
.card__media img {
border-radius: calc(var(--radius) - 14px) calc(var(--radius) - 14px) 0 0;
}
改变浏览器视窗大小,你看到的效果如下:
Fab Four技术也可以指定一个具体的断点宽度:
border-radius: max(0, min(DESIRED_BORDER_RADIUS , ( WIDTH_BREAKPOINT - 1px - 100% ) * 9999))
其中:
DESIRED_BORDER_RADIUS
: 期望的圆角半径,比如示例中的24px
WIDTH_BREAKPOINT
:断点对应的宽度,比如--breakpoint: 400px;
根据应用的要求,Fab Four技术可以运用于多种场景。有时,只要容器的宽度大于一个特定的浏览器视窗的断点,就应该应用一个特定的规则:
border-radius: max(0, min(DESIRED_BORDER_RADIUS , (100% - WIDTH_BREAKPOINT + 1px) * 9999))
比如:
.card {
--DESIRED_BORDER_RADIUS: 24px;
--WIDTH_BREAKPOINT: 400px;
--radius: max(
0px,
min(
var(--DESIRED_BORDER_RADIUS),
(var(--WIDTH_BREAKPOINT) - 100% - 1px) * 9999
)
);
border-radius: var(--radius);
}
.card__media img {
border-radius: calc(var(--radius) - 14px) calc(var(--radius) - 14px) 0 0;
}
.card:last-child {
--radius: max(
px,
min(
var(--DESIRED_BORDER_RADIUS),
(100% - var(--WIDTH_BREAKPOINT) + 1px) * 9999
)
);
}
其实,我更喜欢将--WIDTH_BREAKPOINT
使用 100vw
,这样指定的浏览器断点宽度和视窗大小是一致的。这对于从移动端(比如375px
)视窗宽度时圆角半径为0
到PC端或平板(比如1344px
)视窗宽度时圆角半径为24px
切换更容易。对于开发者而言也更易于理解。
从《聊聊min()
,max()
和clamp()
函数》一文中我们可以得知:
clamp(MIN, VAL, MAX) = max(MIN, min(VAL, MAX))
也就是说,上面的 Fab Four 用到的min()
和max()
可以使用clamp()
来替代:
--radius: max(0px, min(24px, (100vw - 12px - 100%) * 9999));
/* 换成 clamp() */
--radius: clamp(0px, (100vw - 12px - 100%) * 9999, 24px);
有关于 Fab Four 更多的介绍,还可以阅读下面这两篇文章:
可能会出现的 @when ... @else
前段时间在推特上看到 @TerribleMia 发了一个关于CSS的 when/else
的信息:
CSSWG刚刚决定采用 @tabatkins关于
when/else
的提议,并且将其纳入下一级的CSS条件。
有了 @when ... @else
规则之后,有关于 CSS 的条件方面样式规则可能会有很大变化。比如下面这段媒体查询的代码:
/* 如果宽度小于 769px */
@media (max-width: 768px) {
@supports (display: grid) {
div {
grid-template-columns: 1fr;
}
}
@supports not (display: grid) {
@supports (clip-path: circle(1px)) and (transform: skewY(1deg)) {
div {
display: block;
}
.blog img {
clip-path: circle(50%);
}
.showcase {
transform: skewY(10deg);
}
}
@supports not ((clip-path: circle(1px)) and (transform: skewY(1deg))) {
div {
display: block;
}
.blog img {
width: 100%;
height: 100%;
}
}
}
}
/* 如果宽度大于 769px */
@media (min-width: 769px) {
@supports (clip-path: circle(1px)) and (transform: skewY(1deg)) {
div {
grid-template-columns: repeat(3, 1fr);
}
.blog img {
clip-path: circle(50%);
}
.showcase {
transform: skewY(10deg);
}
}
@supports not ((clip-path: circle(1px)) and (transform: skewY(1deg))) {
div {
grid-template-columns: repeat(3, 1fr);
}
.blog img {
width: 100%;
height: 100%;
}
}
}
使用 @when ... @else
可能就是这样了:
@when media(min-width: 768px) and supports(display: grid) {
div {
display: grid;
grid-template-columns: 1fr;
}
} @else supports(clip-path: circle(1px)) and supports(transform: skewY(1deg)) {
div {
display: block;
}
.blog img {
clip-path: circle(50%);
}
.showcase {
transform: skewY(10deg);
}
}
} @else {
div {
display: block;
}
img {
width: 100%;
height: 100%;
}
}
如果上面的代码太过于复杂,我们来看一个更简单的示例。比如以前使用两个媒体查询来写的CSS代码:
@media (min-width: 600px) {
/* ... */
}
@media (max-width: 599px) {
/* ... */
}
使用 @when ... @else
就可以是:
@when media(min-width: 600px) {
/* ... */
}
@else {
/* ... */
}
是不是更易于理解了。
也就是说,这个规则要是得到支持之后,我们要实现一个圆角半径从0
切换到24px
就会更简单。比如:
.card__container {
container: inline-size;
}
.card {
border-radius: 24px;
}
@container (width >= 100vw) {
.card {
border-radius: 0;
}
}
上面的代码演示的是容器查询的使用姿势。要是换成 @when ... @else
就可以是:
@when container(width >= 100vw) {
.card {
border-radius: 0;
}
}
@else {
.card {
border-radius: 24px;
}
}
目前这种方案还只是一种可能,但我们还是希望他们会早点到来。
小结
上面提到的几种实现条件圆角的效果,除了最后一种 @when ... @else
看不到效果之外,其他的几种都得到浏览器的支持。他们有着自己的利弊。比如说,CSS媒体查询出现的最早,但断点不易于判断,不够自动化。CSS锁技术虽然完美,但它并不是真正的实现0
和1
的切换,他实现的是从最小值到最大值的一个期间值。Fab Four 可以实现 0
和 1
的切换,达到我们所说的有条件切换圆角半径,但从上面的示例来看,还是略有一点缺陷。相对而言,我最喜欢的还是容器查询特性,他可以让我们做出更多的选择,不再需要依赖视窗宽度来做判断,可以直接和组件父容器宽度挂钩,有条件给元素设置圆角半径。
最后我想说的是,上面提到的技术只是用于 border-radius
属性上,事实上,它还可以用于其他一些属性上,比如border-width
、font-size
之类的。要是你感兴趣的话,可以尝试一下。