边框不一定是 border 画的:圆角渐变边框按钮的 CSS 方案

设计规范里有一种很常见的元素:圆角的按钮或标签,边框不是一条纯色线,而是带透明度的微妙渐变,背景色还要随业务场景变化。

轮到 CSS 实现时,常规手段会接连碰壁。border 只接受纯色;border-image 支持渐变,但只要它生效,border-radius 就会被忽略,圆角随之失效;伪元素垫底的写法要额外管理层叠,半透明边框还会和元素背景互相污染。

需求合在一起其实是一句话:不加 DOM、不动布局,怎样让元素自身就带一条”圆角 + 渐变 + 半透明”的边框,并且使用方改一个变量就能换掉整套配色?

我的答案是换一个思路:边框不一定是 border 画的。 border 只负责占位,视觉交给两层裁剪方式不同的背景,配色交给 CSS 变量。落地就是一个二十几行的工具类 .gradient-border。

方案全貌:一个工具类的三层分工

先把完整代码放出来。这一节看完就可以抄走,后面的章节解释为什么必须这么写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
.gradient-border {
--gradient-border-color: #e8ebed;

border: 1px solid transparent;
background-color: var(--gradient-border-color);
background-clip: padding-box, border-box;
background-origin: padding-box, border-box;
background-image: linear-gradient(
0deg,
var(--gradient-border-color),
var(--gradient-border-color)
),
linear-gradient(
180deg,
rgba(0, 0, 0, 5.78%) 90.58%,
rgba(0, 0, 0, 16.2%) 100%
);
}

.gradient-border:hover {
--gradient-border-hover-color: var(--gradient-border-color);

background-color: var(--gradient-border-hover-color);
background-image: linear-gradient(
0deg,
var(--gradient-border-hover-color),
var(--gradient-border-hover-color)
),
linear-gradient(
180deg,
rgba(0, 0, 0, 5.78%) 90.58%,
rgba(0, 0, 0, 16.2%) 100%
);
}

抛开 hover 不谈,真正干活的只有三件事:

  1. border: 1px solid transparent:撑出 1px 的物理空间,但自身透明,把这一圈的绘制权让出来;
  2. 两层 background-image:上层纯色裁剪到 padding-box,负责把内部盖平;下层半透明渐变裁剪到 border-box,在 1px 边环里透出来,成为我们看到的那条”边框”;
  3. CSS 变量 --gradient-border-color:内部填充、边框底色都派生自它,是这个类对外开放的唯一配色入口。

自下而上的绘制顺序是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
 ① background-color        铺满 border-box,作为整体底色
② 下层渐变(半透明黑) 裁剪到 border-box,只在 1px 边环可见
③ 上层渐变(纯色) 裁剪到 padding-box,把内部盖平、保持纯净

┌──────────────────────────────┐
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ ← 边框环:下层渐变透出
│░┌──────────────────────────┐░│
│░│ │░│
│░│ 内部:上层纯色背景 │░│ ← padding-box 以内
│░│ │░│
│░└──────────────────────────┘░│
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│
└──────────────────────────────┘

那条渐变”边框”从来不是 border,而是下层背景从透明 border 下面露出来的部分。后面几节的写法,都由这一句展开。

透明 border 占位,边框是背景”露出”的部分

border: 1px solid transparent 经常被理解成”画了一条看不见的边框”。更准确地说,它做了两件彼此独立的事:在盒模型里占住 1px 空间,同时把这 1px 涂成透明。在这 1px 上显色的是背景。

CSS 里 background-image 可以写多层,先声明的层叠在上。这个类利用了两层,各管一段:

  • 上层是 linear-gradient(0deg, var(--gradient-border-color), var(--gradient-border-color)),一个纯色渐变,配合 background-clip 的第一个值 padding-box,只铺满内部。它的职责是让元素内部保持干净、不透明,不受下层渐变影响;
  • 下层是 linear-gradient(180deg, rgba(0,0,0,5.78%) 90.58%, rgba(0,0,0,16.2%)),一个半透明的黑色渐变,配合第二个值 border-box,铺满包含边框在内的整个区域。内部它被上层纯色盖住,只有在 1px 边环里没有遮挡,才透出来叠加在 background-color 之上。

于是边环里呈现的是”主题色 + 一层向下微微加深的黑色”,视觉上就是一条带透明度变化、下缘略深的渐变边框——一种很轻的浮起感。

background-origin: padding-box, border-box 与 clip 成对出现,决定每一层从哪个盒子的边缘开始定位。对上层纯色来说,怎么写结果都一样;对下层那条在 90.58% 处拐弯的渐变,origin 决定这个百分比是相对外边缘还是内边缘计算——写错了,投影的落点就会偏离设计稿一两个像素。成对书写,是让每一层的坐标系和裁剪范围始终一致。

最后回答开头需求里的”圆角”:背景裁剪天然跟随 border-radius 的曲线。 所以这个类自己一个圆角都不用写,使用方想要多圆就配多圆——rounded、rounded-full、rounded-[99px] 都可以直接往上叠。这一点恰恰是 border-image 方案的死穴,下一节展开。

为什么不是 border-image、伪元素和 box-shadow

定方案时我试过三条更”直觉”的路,每一条都死在不同的地方。

border-image:解决了渐变,杀死了圆角。 按 CSS 背景规范,border-image 的渲染不受 border-radius 裁剪——背景会被裁到圆角曲线内,边框图像不会。需求的第二条就是圆角,这条路不用往下走了。

box-shadow:能跟着圆角走,但画不出渐变。 阴影的模糊和扩散可以做出柔和的层次,但一道阴影只是元素轮廓的单色副本,靠模糊半径做衰减,表达不了”1px 的环上颜色沿方向变化”。

伪元素:都能做到,但代价藏在后面。 常见写法是 ::before 绝对定位铺满元素,带上渐变背景和 padding: 1px,再用 mask-composite 把中间挖空只留一圈。它确实同时满足圆角和渐变,但引入三笔成本:伪元素名额被永久占用,这个元素的 ::before 从此不能再干别的;mask-composite 要处理 -webkit- 前缀与标准写法的并存,代码量跟着翻倍;层叠中多了一个绝对定位元素,后续所有交互态都得重新确认它的层级。

四个约束——圆角、渐变、半透明、背景可变——逐条对完会发现:双层背景是唯一不需要额外 DOM、不需要 mask、不扰乱层叠的写法。这也是它适合被收进设计系统的原因:复杂的部分只有二十几行,且只写一次。

CSS 变量:把配色开放成这个类的 API

内部填充和边框底色都派生自同一个变量,意味着使用方改一处,整套视觉就换掉了:

1
2
3
4
5
6
7
8
9
<span
className="gradient-border rounded-full px-4 h-6 flex items-center"
style={{
// @ts-ignore:React 的 CSSProperties 类型不认识自定义属性
'--gradient-border-color': '#FFF',
}}
>
P{priority}
</span>

hover 的处理用了一个变量回退的小技巧:

1
2
3
4
.gradient-border:hover {
--gradient-border-hover-color: var(--gradient-border-color);
/* background-color 与两层渐变同步切换到 hover 变量 */
}

回看方案全貌里的完整代码,hover 块是把 background-color 和两层渐变原样重写了的。为什么不只声明一个变量?因为 background-image 里的 var() 是实时解析的——值跟着变量走,但引用的是哪个变量,在声明写下的那一刻就定死了。基础态的三层背景引用 --gradient-border-color,hover 态要让同一套绘制改读 --gradient-border-hover-color,只能把三层声明重写一遍、同步换掉变量名,没有”只换变量引用”的捷径。

这条声明的意思是:默认情况下 hover 颜色等于基础色,也就是”没有 hover 变化”。但它写在类选择器里,优先级低于内联样式,所以使用方在元素上内联覆盖 --gradient-border-hover-color,就能单独定制悬停态;不覆盖也不会出问题。可变是能力,不变是默认值,两者靠层叠优先级实现,而不是靠 if-else。

实际项目里,这个类最终服务了徽章、优先级标签、反馈卡片和模拟下拉触发器等好几种形态各异的元素。它们的圆角、尺寸和配色完全不同,共享的只有那二十几行,这正是把配色做成变量、把圆角让给使用方的回报。

同一套 .gradient-border:圆角、尺寸、配色各不相同的徽章、标签、卡片与下拉触发器

顺带一提那行 // @ts-ignore:在 React 里用内联 style 写 CSS 自定义属性时,TypeScript 的类型定义不包含 --* 键,需要忽略或断言。这是工程上的小刺,与方案本身无关,但第一次接入的人几乎都会踩一下。

两处”看着奇怪”的写法,都有出处

为什么内部填充要用 linear-gradient(0deg, color, color) 这种纯色渐变,而不是只写 background-color?

因为 background-color 不能被分层裁剪——按规范,它的裁剪范围跟随最底层背景的 clip 值,这里是 border-box。也就是说,如果只写 background-color,下层那个半透明黑渐变会直接铺满整个元素内部,把内容区也整体罩暗。两层填充都必须走 background-image,才能各自声明 clip:上层纯色把内部盖平,下层的颜色变化才只留在边环里。

那 background-color 是不是多余的?也不是。它垫在所有背景层之下,让边环里的半透明渐变叠在主题色上,而不是叠在父元素的背景上。没有它,那条”边框”的底色会随外部容器漂移,配色变量也就失去了意义。

rgba(0,0,0,5.78%) 90.58% → rgba(0,0,0,16.2%) 这组精确到小数点的数值是哪来的?

直接还原自设计稿。值得拆开看的是它的结构:从顶部到 90.58% 的位置,颜色恒定为 5.78% 的黑,只有最后约十分之一的高度里才过渡到 16.2%——加深几乎全部发生在元素下边缘附近。边环上缘是主题色叠约 6% 的黑,下缘叠约 16% 的黑。

这不是一条普通意义的”渐变边框”,而是一个被压扁进 1px 的投影:上浅下深,元素因此看起来微微浮起。设计稿里那些看似随手写下的小数,往往都对应着这种精确的体感意图,实现时值得原样保留,而不是取整成”好算”的值。

结语:把视觉效果拆回正确的层

回到开头那道题。它的解法不是找到一个更强的 border 属性——CSS 里没有这样的属性——而是承认一件事:边框、填充、配色是三个独立的视觉职责,只是恰好都能由 border 属性一并承担而已。 一旦设计稿超出 border 的能力,就该把它们拆开,各自找回正确的层:占位交给 border,绘制交给背景,配色交给变量。

以后再遇到”CSS 表达不了设计稿”的时刻,可以先问一句:这个视觉效果本质上属于哪一层?这个问题,往往比满世界找替代属性更快有答案。

最后补一句适用边界。这个方案的前提是元素背景完全由自己掌控,多层背景的阅读成本也客观存在。所以它适合像 .gradient-border 这样封装成设计系统的工具类,由一处消化全部复杂度;不适合在业务代码里散落着手写——那会让每一处都变得难以维护,也违背了把它写出来的初衷。

兼容性同样在这个边界之内。逐层取值的 background-clip / background-origin 与多重背景本身同级,IE9 起便支持;真正的门槛是 CSS 自定义属性——Chrome 49+、Firefox 31+、Safari 9.1+、Edge 15+ 没有问题,IE 全系列无缘。面向现代浏览器的中后台系统和设计系统,这不是障碍;仍需兼容 IE 的老项目,这个方案从一开始就不在选项里。