边框不一定是 border 画的:圆角渐变边框按钮的 CSS 方案
设计规范里有一种很常见的元素:圆角的按钮或标签,边框不是一条纯色线,而是带透明度的微妙渐变,背景色还要随业务场景变化。
轮到 CSS 实现时,常规手段会接连碰壁。border 只接受纯色;border-image 支持渐变,但只要它生效,border-radius 就会被忽略,圆角随之失效;伪元素垫底的写法要额外管理层叠,半透明边框还会和元素背景互相污染。
需求合在一起其实是一句话:不加 DOM、不动布局,怎样让元素自身就带一条”圆角 + 渐变 + 半透明”的边框,并且使用方改一个变量就能换掉整套配色?
我的答案是换一个思路:边框不一定是 border 画的。 border 只负责占位,视觉交给两层裁剪方式不同的背景,配色交给 CSS 变量。落地就是一个二十几行的工具类 .gradient-border。
方案全貌:一个工具类的三层分工
先把完整代码放出来。这一节看完就可以抄走,后面的章节解释为什么必须这么写。
1 | .gradient-border { |
抛开 hover 不谈,真正干活的只有三件事:
border: 1px solid transparent:撑出 1px 的物理空间,但自身透明,把这一圈的绘制权让出来;- 两层
background-image:上层纯色裁剪到padding-box,负责把内部盖平;下层半透明渐变裁剪到border-box,在 1px 边环里透出来,成为我们看到的那条”边框”; - CSS 变量
--gradient-border-color:内部填充、边框底色都派生自它,是这个类对外开放的唯一配色入口。
自下而上的绘制顺序是这样:
1 | ① background-color 铺满 border-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 | <span |
hover 的处理用了一个变量回退的小技巧:
1 | .gradient-border:hover { |
回看方案全貌里的完整代码,hover 块是把 background-color 和两层渐变原样重写了的。为什么不只声明一个变量?因为 background-image 里的 var() 是实时解析的——值跟着变量走,但引用的是哪个变量,在声明写下的那一刻就定死了。基础态的三层背景引用 --gradient-border-color,hover 态要让同一套绘制改读 --gradient-border-hover-color,只能把三层声明重写一遍、同步换掉变量名,没有”只换变量引用”的捷径。
这条声明的意思是:默认情况下 hover 颜色等于基础色,也就是”没有 hover 变化”。但它写在类选择器里,优先级低于内联样式,所以使用方在元素上内联覆盖 --gradient-border-hover-color,就能单独定制悬停态;不覆盖也不会出问题。可变是能力,不变是默认值,两者靠层叠优先级实现,而不是靠 if-else。
实际项目里,这个类最终服务了徽章、优先级标签、反馈卡片和模拟下拉触发器等好几种形态各异的元素。它们的圆角、尺寸和配色完全不同,共享的只有那二十几行,这正是把配色做成变量、把圆角让给使用方的回报。

顺带一提那行 // @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 的老项目,这个方案从一开始就不在选项里。