移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

812 阅读4分钟

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

一个让产品经理和设计师都满意的卡片边框方案

📖 前言

上周设计突然甩过来一张图,问我能不能不切图做出这种效果?

image.png

我蒙了一下第一反应感觉可以,无非就是常规的伪类+渐变。但尝试了一下发现两个致命问题:

1、border-image支持渐变但不支持每条边自定义设置;

2、使用伪类可以解决线的问题但是不能解决圆角问题;

忙乎半天又问了问ai感觉还是实现起来不容易,但随后产品过来又是那老一套。拿着别人家的产品看人家这个如何好看,如何优雅,巴拉巴拉。大有一种:

别人能做,你做不了。

这是不能接受的,于是又潜心研究了下,有了最后的效果。

🎯 需求拆解

先梳理一下具体需求:

需求描述
位置卡片默认右上角 L 形(右边 + 上边),支持修改角的位置
渐变右上角颜色最深,向两端渐淡
粗细视觉上 1px
长度右边和上边长度大致相等
圆角适配卡片 20px 圆角
性能纯 CSS,无图片,无 SVG

看起来简单,做起来全是坑。

🧪 方案探索

方案一:两个伪元素分别画线

最直观的想法:用 ::before 画底部线,::after 画左边线。

scss

.card {
  &::before {
    // 底部线
    background: linear-gradient(90deg, gold, transparent);
  }
  &::after {
    // 左边线
    background: linear-gradient(0deg, gold, transparent);
  }
}

问题:两条线在圆角处有接缝,怎么都对不齐。调整了半天,还是能看到明显的拼接痕迹。

结论:放弃,圆角处无法完美衔接。


方案二:SVG 路径描边

SVG 可以精确控制路径和圆角,效果确实完美。

问题

  • 需要额外 HTML 结构
  • 移动端多一个网络请求或内联代码
  • 响应式适配需要额外处理

结论:能用,但不够优雅,性能也不够极致。


方案三:border-image + 渐变

scss

border-image: radial-gradient(circle at bottom left, gold, transparent) 1;

问题border-image 会覆盖四边,无法只控制左下角。

结论:放弃。


方案四:radial-gradient + mask(最终方案)

经过多次尝试,我发现径向渐变的圆心在左下角时,渐变会自然地向左和向上扩散,形成完美的 L 形。

配合 mask 组合,可以精确控制只显示边框区域,而不是整个渐变圆。

完美解决所有问题!

💻 最终代码

以下是基于vue2的一个组件CornerGradientCard,开箱即用。

但注意基于他的点击事件要使用click.native!!!

<template>
   <div
        :class="wrapperClass"
        :style="wrapperStyle"
   >
        <div
            class="gradient-wrapper__content"
            :style="{ borderRadius: radiusRem }"
        >
            <slot></slot>
        </div>
    </div>
  </template>

  <script>
  /** 与 postcss.config.js 中非 vant 资源的 rootValue(75) 一致,设计稿 px → rem */
  const POSTCSS_ROOT_VALUE = 75
  function pxToRem(px) {
    const n = Number(px)
    if (Number.isNaN(n)) return '0rem'
    return `${parseFloat((n / POSTCSS_ROOT_VALUE).toFixed(10))}rem`
  }

  /** 渐变角落:控制径向渐变原点与光晕块位置 */
  const GRADIENT_POSITION_KEYS = ['top-right', 'top-left', 'bottom-right', 'bottom-left']

  const GRADIENT_POSITION_MAP = {
    'top-right': {
      at: '100% 0%',
      beforeAt: '110% 20%'
    },
    'top-left': {
      at: '0% 0%',
      beforeAt: '-10% 20%'
    },
    'bottom-right': {
      at: '100% 100%',
      beforeAt: '110% 80%'
    },
    'bottom-left': {
      at: '0% 100%',
      beforeAt: '-10% 80%'
    }
  }

  export default {
    props: {
      type: {
        default: '',
        type: String
      },
      radius: {
        default: 12,
        type: Number
      },
      marginBottom: {
        default: 14,
        type: Number
      },
      /** 渐变所在角落,默认右上角 */
      position: {
        type: String,
        default: 'top-right',
        validator(v) {
          return GRADIENT_POSITION_KEYS.includes(v)
        }
      }
    },
    computed: {
      radiusRem() {
        return pxToRem(this.radius)
      },
      gradientPosResolved() {
        return GRADIENT_POSITION_MAP[this.position] || GRADIENT_POSITION_MAP['top-right']
      },
      wrapperClass() {
        const pos = GRADIENT_POSITION_KEYS.includes(this.position)
          ? this.position
          : 'top-right'
        return ['gradient-wrapper', this.type, `gradient-pos--${pos}`].filter(Boolean).join(' ')
      },
      wrapperStyle() {
        const r = this.radiusRem
        const { at, beforeAt } = this.gradientPosResolved
        return {
          borderRadius: r,
          marginBottom: pxToRem(this.marginBottom),
          '--corner-radius': r,
          '--gradient-at': at,
          '--gradient-before-at': beforeAt
        }
      }
    }
  }
  </script>

  <style lang="scss" scoped>
    $gradient-first-percent: 2%;    // 第一个实色节点百分比
    $gradient-second-percent: 14%;  // 第二个半透明节点百分比
    $gradient-transparent-percent: 20%; // 透明节点百分比

    .gradient-wrapper {
        width: 100%;
        position: relative;
        box-sizing: border-box;
        background-color: #fff;

        &__content {
            width: 100%;
            position: relative;
            z-index: 3;
            box-sizing: border-box;
            overflow: hidden;
            background-color: transparent;
        }

        // 与渐变角落一致的 1px 内边距(原实现为左下角)
        &.gradient-pos--top-right {
            padding: 1px 1px 0 0;
            .gradient-wrapper__content {
                margin: 1px 1px 0 0;
            }
        }
        &.gradient-pos--top-left {
            padding: 1px 0 0 1px;
            .gradient-wrapper__content {
                margin: 1px 0 0 1px;
            }
        }
        &.gradient-pos--bottom-right {
            padding: 0 1px 1px 0;
            .gradient-wrapper__content {
                margin: 0 1px 1px 0;
            }
        }
        &.gradient-pos--bottom-left {
            padding: 0 0 1px 1px;
            .gradient-wrapper__content {
                margin: 0 0 1px 1px;
            }
        }

        // 渐变边框线(核心)
        &::after {
            content: '';
            position: absolute;
            z-index: 2;
            bottom: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border-radius: var(--corner-radius);
            pointer-events: none;
            mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            mask-composite: exclude;
            -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            -webkit-mask-composite: xor;
            padding: 1px;
        }

        // 渐变底色(光晕效果),角落由 .gradient-pos--* 定位
        &::before {
            content: '';
            position: absolute;
            z-index: 1;
            width: 143px;
            height: 73px;
            border-radius: var(--corner-radius);
            filter: blur(10px);
            pointer-events: none;
        }

        &.gradient-pos--top-right::before {
            top: 1px;
            right: 1px;
            bottom: auto;
            left: auto;
        }
        &.gradient-pos--top-left::before {
            top: 1px;
            left: 1px;
            bottom: auto;
            right: auto;
        }
        &.gradient-pos--bottom-right::before {
            bottom: 1px;
            right: 1px;
            top: auto;
            left: auto;
        }
        &.gradient-pos--bottom-left::before {
            bottom: 1px;
            left: 1px;
            top: auto;
            right: auto;
        }

         // 观点
         &.CHIEF {
          &::after {
              background: radial-gradient(
              circle at var(--gradient-at),
              #FFC7B3 $gradient-first-percent,
              rgba(255, 233, 226, 0.65) $gradient-second-percent,
              transparent $gradient-transparent-percent
              );
          }
          &::before {
              background: radial-gradient( 80% 80% at var(--gradient-before-at), #FFF7F4 0%, rgba(255,255,255,0) 100%);
              // background: radial-gradient( 80% 80% at 110% 20%, red 0%, rgba(255,255,255,0) 100%);
              
          }
        }

        // 微信
        &.WX {
          &::after {
              background: radial-gradient(
              circle at var(--gradient-at),
              #B6E2C8 $gradient-first-percent,
              #DFF7EA $gradient-second-percent,
              transparent $gradient-transparent-percent
              );
          }
          &::before {
              background: radial-gradient( 83% 83% at var(--gradient-before-at), #EFFCF4 0%, rgba(239,255,246,0) 100%);
          }
        }
  }
</style>



 <CornerGradientCard
    v-for="(item, index) in infoData"
    :key="item.id"
    :id="item.id"
    :type="item.type"
    @click.native="clickItem(item)"
>
    <!-- 卡片内容 -->
</CornerGradientCard>
    

🎨 参数调节指南

参数位置作用移动端建议
padding: 1px.wrapper边框粗细保持 1px
4% / 10% / 30%径向渐变边框长度根据卡片大小调整
blur(10px)光晕柔和度移动端 8-12px 较佳
border-radius全局圆角与设计稿保持一致

当然,基于此样式还可以可发出各种变种,例如将渐变等放到常规的右上角,替代常规的卡片标签展示样式。

评论区有小伙伴说不是很好看,我个人也觉得一般,但是将渐变调整到右上角后感觉还可以,最终效果如下。

image.png

如果这篇文章对你有帮助,烦请动动发财的小手点个赞~