普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月17日首页

借助CSS实现自适应屏幕边缘的tooltip

作者 XboxYan
2025年8月17日 13:23

欢迎关注我的公众号:前端侦探

tooltip是一个非常常见的交互,一般用于补充文案说明。比如下面这种(蓝色边框表示屏幕边缘)

image-20250523223912070

通常tooltip都会有一个固定的方向,比如top表示垂直居中向上。

但是,如果提示文案比较多,提示区域右比较靠近屏幕边缘,就可能出现这种情况

image-20250523224309231

直接超出屏幕!这很显然是不能接受的。

你可能会想到改变一下对齐方向,比如top-right,但是这里的文案可能是不固定的,也就是会出现这样

image-20250523224706671

嗯...感觉无论怎么对齐都会有局限。那么如何解决呢?一起看看吧

一、理想中的自适应对齐

我们先想想,最完美的对齐是什么样的。

其实没那么复杂,就分两种情况,一个居左,一个居右

1.居左

正常情况下,就是垂直居中朝上

image-20250523225707868

如果提示文本比较多,那就靠左贴近文本容器对齐

image-20250523225826347

如果提示文本继续增加,那就整行换行,并且不超过文本容器

image-20250523230041333

2. 居右

正常情况下,也是垂直居中朝上

image-20250523230849249

如果提示文本比较多,那就靠右贴近文本容器对齐

image-20250523230936187

如果提示文本继续增加,也是整行换行,并且不超过文本容器

image-20250523231035167

那么如何实现这样的对齐方式呢?

二、左自适应对齐的思路

我们先看第一种情况,看似好像有3种对齐方式,而且还要监测是否到了边界,好像挺复杂。其实换个角度,其实是这样一种规则

  1. 当内容较少时,居中对齐
  2. 当内容较多时,居左对齐
  3. 当内容多到换行时,有一个最大宽度

既然涉及到了对齐,那就有对齐的容器和被对齐的对象。

我们可以想象一个虚拟容器,以对齐中心(下图问号图标)向两边扩展,一直到边界处,如下所示(淡蓝色区域)

image-20250523233837924

假设HTML如下

<span class="tooltip" title="提示"></span>

当气泡文本比较少时,可以通过文本对齐实现居中,气泡可以直接通过伪元素实现

.tooltip{
  width: 50px; /*虚拟容器宽度,暂时先固定 */
  text-align:center;
}
.tooltip::before{
  content: attr(title);
  display: inline-block;
  color: #fff;
  background-color: #000;
  padding: .5em 1em;
  border-radius: 8px;
  box-sizing: border-box;
}
/*居中箭头*/
.tooltip::after{
  content: '';
  position: absolute;
  width: 1em;
  height: .6em;
  background: #000;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  top: 0;
  left:0;
  right:0;
  margin: 0 auto;
  transform: translateY(-150%)
}

使用文本居中,也就是text-align: center有个好处,当文本不超过容器时,居中展示,就如同上图展示一样。

当文本比较多时,默认会换行,效果如下

image-20250524112135696

这样应该很好理解吧。

我们需要气泡里的文本在多行时居左,可以直接给气泡设置居左对齐

.tooltip::before{
  /*...*/
  text-align: left;
}

效果如下

image-20250524112333960

这样就实现了单行居中,多行居左的效果了。

现在还有一个问题,如何在气泡文本较多时,不被对齐容器束缚呢?

首先可以想到的是禁止换行,也就是

.tooltip::before{
  /*...*/
  white-space: nowrap
}

这样在文本不超过一行时确实可以

image-20250524112641734

看,已经突破了容器束缚。但是文本继续增加时,也会出现无法换行的问题

image-20250524112800984

我们可以想一想,还有什么方式可以控制换行呢?

这里,我们需要设置宽度为最大内容宽度,相当于文本有多少,文本容器就有多宽

.tooltip::before{
  /*...*/
  width: max-content
}

看似好像和不换行一样

image-20250524112800984

实则不然,我们并没用禁止换行。只要给一个最大宽度,立马就换行了

.tooltip::before{
  /*...*/
  width: max-content;
  max-width: 300px;
}

效果如下

image-20250524113318010

是不是几乎实现了我们想要的效果了?

不过,这里涉及了两个需要动态计算的宽度,一个是虚拟容器宽度,还有一个是外层最大宽度,

image-20250524152008282

下面看如何实现

三、借助JS计算所需宽度

现如今,外层的最大宽度倒是可以通过容器查询获得,但内部的虚拟容器宽度还无法直接获取,只能借助JS了。

不过我们这里可以先只计算左侧偏移,也就是一半的宽度

image-20250524155257344

具体实现如下

//问号中心到左侧距离
const x = this.offsetLeft - 8
// 问号的宽度
const w = this.clientWidth
// 外层整行文本容器宽度
const W = this.offsetParent.clientWidth - 32
// 左侧偏移
this.style.setProperty('--x', x + 'px')
// 外层文本容器宽度(气泡最大宽度)
this.style.setProperty('--w', W + 'px')

然后给前面待定的宽度绑定这些变量就行了

.tooltip{
  /*...*/
  width: calc(var(--x) * 2);
}
.tooltip::before{
  /*...*/
  max-width: var(--w);
}

这样左侧就完全实现自适应了,无需实时计算,仅需初始化一次就好了

Kapture 2025-05-24 at 15.56.13转存失败,建议直接上传图片文件

四、完全自适应对齐

前面是左侧,那右侧如何判断呢?我们可以比较左侧距离的占比,如果超过一半,就表示现在是居右了

这里用一个属性表示

this.tooltip.dataset.left = x/W < 0.5 //是否居左

然后就右侧虚拟容器的宽度了,和左侧还有有点不一样

image-20250524160146516

前面我们已经算出了左侧距离,由于超过了一半,所以需要先减然后再乘以二

.tooltip[data-left="false"]::before{
  /*...*/
  width: calc( (var(--w) - var(--x)) * 2);
  max-width: var(--w);
}

其实这里还是有个小问题的,当气泡文字比较长时,仍然是朝右突破了边界,如下所示

image-20250524160531721

这是因为默认的语言流向造成的(从左往右),解决这个问题也非常简单,仅需改变语言方向就可以了,要用到direction:rtl,如下

.tooltip[data-left="false"]::before{
  /*...*/
  width: calc( (var(--w) - var(--x)) * 2);
  max-width: var(--w);
  direction: rtl;
}

这样就完美了

image-20250524160856055

现在来看一下所有边界情况的演示

Kapture 2025-05-24 at 16.10.06

你也可以访问在线demo真实体验:codepen.io/xboxyan/pen…

如果你是 vue3 项目,可以直接用这段封装好的组件(其实没几行代码,大部分自适应都是CSS完成的)

<!-- 极度自适应的tooltips -->
<script setup lang="ts">
const props = defineProps({
  text: String,
  gap: {
    type: Number,
    default: 12,
  },
})

const show = ref(false)
const pos = reactive({
  x: 0,
  w: 0,
  top: 0,
  gap: 0,
  isLeft: true,
})
const click = (ev: MouseEvent) => {
  // console.log()
  // if (ev.target) {
  //   ev.stopPropagation()
  // }
  const target = ev.target as Element | null
  console.log('xxxxxxxxxxx', target)
  if (target) {
    const { x, y, width } = target.getBoundingClientRect()
    pos.top = y + window.scrollY
    pos.gap = props.gap
    pos.x = x + width / 2 - props.gap
    pos.w = window.innerWidth - props.gap * 2
    show.value = true
  }
}

const wrap = ref<HTMLElement>()

document.body.addEventListener('touchstart', (ev) => {
  // 没有点击当前触发对象就隐藏tooltips
  if (!(wrap.value && ev.target && wrap.value.contains(ev.target as Node))) {
    show.value = false
  }
})
</script>

<template>
  <span class="wrap" ref="wrap" @click="click">
    <slot></slot>
  </span>
  <Teleport to="body">
    <div
      class="tooltip"
      v-show="show"
      :data-title="text"
      :data-left="pos.x / pos.w < 0.5"
      :style="{
        '--x': pos.x + 'px',
        '--top': pos.top + 'px',
        '--gap': pos.gap + 'px',
        '--w': pos.w + 'px',
      }"
    ></div>
  </Teleport>
</template>
<style>
.wrap {
  display: contents;
}
.tooltip {
  position: absolute;
  top: var(--top);
  text-align: center;
  pointer-events: none;
}
.tooltip[data-left='true'] {
  width: calc(var(--x) * 2);
  left: var(--gap);
}
.tooltip[data-left='false'] {
  width: calc((var(--w) - var(--x)) * 2);
  right: var(--gap);
  direction: rtl;
}

.tooltip::before {
  content: attr(data-title);
  display: inline-block;
  color: #fff;
  background-color: #191919;
  padding: 0.5em 0.8em;
  border-radius: 8px;
  transform: translateY(calc(-100% - 0.5em));
  width: max-content;
  max-width: var(--w);
  box-sizing: border-box;
  text-align: left;
}
.tooltip::after {
  content: '';
  position: absolute;
  width: 1.2em;
  height: 0.6em;
  background: #000;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  top: 0;
  left: 0;
  right: 0;
  margin: 0 auto;
  transform: translateY(calc(-100% - 0.2em));
}
</style>

五、推荐一个开源库

其实市面上有一个库可以完成类似的交互,叫做 float-ui

image-20250817104551464转存失败,建议直接上传图片文件

这个是专门做popover这类交互的,其中有一个shift属性,可以做这种跟随效果

image-20250817104816034

不过对于大部分情况,引入一个单独的库还是成本偏大,建议还是纯原生实现。

这样一个极度自适应的气泡组件,你学会了吗,赶紧在项目中用起来吧~最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发 ❤❤❤

❌
❌