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-widthmax-widthwidth 设置元素尺寸的一种技术。

事实上,它也是:

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 代码,并使用 ResizeObserverMutationObserver 实现容器查询功能!

有关于 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 上,还可以使用 @AdrianBeceDevModern 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)) 计算出来的值是0px24px

注意,比较函数 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锁技术虽然完美,但它并不是真正的实现01的切换,他实现的是从最小值到最大值的一个期间值。Fab Four 可以实现 01 的切换,达到我们所说的有条件切换圆角半径,但从上面的示例来看,还是略有一点缺陷。相对而言,我最喜欢的还是容器查询特性,他可以让我们做出更多的选择,不再需要依赖视窗宽度来做判断,可以直接和组件父容器宽度挂钩,有条件给元素设置圆角半径。

最后我想说的是,上面提到的技术只是用于 border-radius 属性上,事实上,它还可以用于其他一些属性上,比如border-widthfont-size 之类的。要是你感兴趣的话,可以尝试一下。