自訂文字裝飾

請使用原生 text-decoration 屬性設定底線、頂線和刪除線的樣式,不要使用邊框、背景漸層或虛擬元素偽造這些樣式。原生裝飾會跟隨文字跨越換行、追蹤字型指標、略過字符下緣,並透過巢狀元素繼承,因此不需要重新實作。只有在裝飾必須勾勒或包覆文字,而非沿著文字排列時,才需要使用本指南結尾介紹的方塊技術。

文字裝飾適用於任何文字,不限於連結。如果底線文字不是連結,請確保使用者不會誤認。修飾連結時,請使用指向目的地的實際 <a href> (新增 target="_blank" rel="noopener" 以開啟新分頁),而非使用指令碼處理常式的 href="javascript:void(0)"。真正的連結會保留中鍵點擊、複製連結和螢幕閱讀器行為。

設定線條樣式

使用 text-decoration 簡寫設定線條、樣式、顏色和粗細,並使用 text-underline-offset 設定與文字的間距。將選取器包裝在 :where() 中,這樣預設值就不會有任何特殊性,且任何後續覆寫都會優先,不需要自訂屬性。

:where(.decorated) {
  /* Thickness is relative to the font size (0.08em is an example value), so the line is not too heavy
     on small text or too light on large text, and max() keeps it at least 1px so it never disappears. */
  text-decoration: underline solid currentColor max(1px, 0.08em);
  /* Use em so the gap scales with the font size. 0.2em clears most descenders; example value. */
  text-underline-offset: 0.2em;
}

/* Example hover state: a softer line that firms up on hover and focus. The 50% mix and
   0.12em thickness are example values. */
:where(.decorated) {
  text-decoration-color: color-mix(in srgb, currentColor, transparent);
}

:where(.decorated:is(:hover, :focus-visible)) {
  text-decoration-color: currentColor;
  text-decoration-thickness: max(1px, 0.12em);
}

/* Other lines are a different text-decoration-line on the same properties */
.decorated-over    { text-decoration-line: overline; }
.decorated-through { text-decoration-line: line-through; }

text-decoration-style 接受 solid、double、dotted、dashed 和 wavy。text-underline-offset 僅適用於底線。

設計起點:

  • 粗細:本文文字約為 0.06em 至 0.1em。標題和強調狀態則可使用較粗的字重 (0.1em 至 0.15em)。
  • 偏移:約 0.15em 至 0.25em。虛線和波浪線需要比實線更多的空間,因此請提高偏移量。
  • 顏色: currentColor 會讓線條與文字顏色一致。文字顏色採用較柔和的混合色,滑鼠懸停時會變深,讓長串連結看起來更柔和。
  • 樣式: solid 用於連結、dotted 用於字詞和縮寫,wavy 用於錯誤等註解。

控制略過行的位置

墨水斷斷續續

根據預設,底線和頂線會在與字符部分 (例如下緣) 交錯時中斷。設定 text-decoration-skip-ink 選擇行為:

.decorated {
  /* auto: skip ink for most scripts (default). none: draw straight through.
     all: also skip ink in scripts where auto does not, such as Japanese or Chinese. */
  text-decoration-skip-ink: all;
}
  • 拉丁文字請保留 auto,且不需宣告,因為這是預設值。
  • 如果底線與基準線的距離太近,會穿過表意字元,或混合文字必須看起來一致,請使用 all。
  • 如要使用非常粗的線條或刻意裝飾的線條,請使用 none,這樣間隙看起來會像斷點。

略過空格

text-decoration-skip-spaces 決定是否在空格下方繪製線條。預設值 start end 會讓開頭和結尾的空格保持原樣。

.decorated {
  /* Spaces collapse by default, so there is nothing to skip. Preserve them when the
     leading or trailing spaces are meaningful, such as padded input values. */
  white-space: pre;
  /* none: decorate every space. all: skip every space, so each word is underlined separately.
     start | end | start end: skip only leading, trailing, or both. */
  text-decoration-skip-spaces: all;
}

除非裝飾應明顯覆蓋邊框間距 (none) 或在每個字詞之間中斷 (all),否則請勿宣告 text-decoration-skip-spaces。一般內嵌文字 (例如連結文字) 沒有前置或後置空格,因此宣告不會有任何作用。

使用內嵌修剪或延伸線條

text-decoration-inset:移動線條的起點和終點。正值會將端點向內拉,負值則會向外推。一個值會同時移動兩端;兩個值則分別設定起點和終點。百分比是指裝飾方塊的內嵌大小。

/* Small insets soften the ends of the line; larger ones read as a deliberately short line.
   Both values are examples. */
.trimmed  { text-decoration-inset: var(--deco-inset, 0.1em); }
.extended { text-decoration-inset: calc(var(--deco-inset, 0.1em) * -1); }

如要在懸停時顯示底線,但不顯示邊框或背景漸層,請將結尾修剪 100%,然後轉換回 0:

.reveal {
  text-decoration: underline; /* Always visible, including for reduced motion */
}

/* Only hide and animate the line when the user has not asked for reduced motion. */
@media (prefers-reduced-motion: no-preference) {
  .reveal {
    /* Trim the end by the full inline size so the line starts with zero length. */
    text-decoration-inset: 0 100%;
    /* 150ms to 300ms feels responsive without lagging; ease-out starts quickly. Example values. */
    transition: text-decoration-inset var(--reveal-duration, 0.25s) ease-out;
  }

  /* Include :focus-visible so keyboard users get the same reveal as pointer users. */
  .reveal:is(:hover, :focus-visible) {
    text-decoration-inset: 0 0;
  }
}

使用虛擬元素繪製自訂線條

如果線條必須是 text-decoration 無法繪製的形狀 (例如波浪線),請使用虛擬元素,並透過裁剪顯示。不需要額外標記,因此連結會維持純文字 <a>。

<a class="draw" href="#">Hover to draw underline</a>
.draw {
  position: relative;
  display: inline-block;
  color: inherit;
  text-decoration: none;
  padding-block-end: 0.4em; /* Space for the line below the text; example value */
}

.draw::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  block-size: 0.4em; /* Height of the squiggle; example value */
  pointer-events: none;
  border: var(--deco-thickness, max(1px, 0.08em)) solid var(--deco-color, currentColor);
  /* A single shape strokes along its path, using the border width as the stroke. Example squiggle. */
  border-shape: shape(from 0% 50%,
    curve to 20% 50% with 5% 0% / 15% 100%,
    smooth to 40% 50% with 35% 100%,
    smooth to 60% 50% with 55% 100%,
    smooth to 80% 50% with 75% 100%,
    smooth to 100% 50% with 95% 100%);
}

/* Only hide and animate the line when the user has not asked for reduced motion;
   otherwise it stays fully drawn. */
@media (prefers-reduced-motion: no-preference) {
  .draw::after {
    /* Clip the line away from the right edge. The negative block insets keep the stroke
       from being cut off vertically. */
    clip-path: inset(-1em 100% -1em 0);
    /* A slightly slower draw than a hover reveal reads as drawing; example duration. */
    transition: clip-path var(--draw-duration, 0.6s) ease-out;
  }

  .draw:is(:hover, :focus-visible)::after {
    clip-path: inset(-1em 0 -1em 0); /* Fully drawn */
  }
}

改用方塊框住文字

有兩個方塊屬性會以框線裝飾文字,而不是沿著文字畫線。為文字加上邊框間距,避免與外框重疊。

/* Gradient outline with a see-through middle */
.ring {
  padding: 0.2em 0.75em; /* Example values: enough room that the outline does not touch the text */
  /* The border width is the visible outline thickness. Relative to the font size, with a 2px
     minimum so it stays visible on small text; example value. */
  border: var(--ring-width, max(2px, 0.12em)) solid transparent;
  background: linear-gradient(135deg, var(--ring-start, #2563eb), var(--ring-end, #db2777)) border-box;
  /* Paint the background only under the border, so the inside stays transparent. */
  background-clip: border-area;
}

/* Border that follows a shape that border-radius cannot draw, such as a tag with a pointed end */
.tag {
  padding: 0.2em 1.5em 0.2em 0.9em; /* Example values: extra room at the point so the text is not clipped */
  border: var(--shape-stroke, max(2px, 0.08em)) solid var(--shape-color, currentColor); /* Example stroke width */
  /* A single shape strokes along its path. Two shapes fill the area between them.
     This polygon is an example; use shape() for curves. */
  border-shape: polygon(0 0, 85% 0, 100% 50%, 85% 100%, 0 100%);
}

border-shape 只會取代邊框。背景仍是矩形方塊,因此請勿依賴背景填滿形狀。

如果只有角落需要不同形狀,例如斜角、凹角或缺角標籤,請改用 corner-shape 搭配 border-radius。這項屬性可讓背景和邊框沿著角落顯示,不支援的瀏覽器則會改用一般的圓角。

.label {
  padding: 0.2em 0.9em; /* Example values */
  border: var(--shape-stroke, max(2px, 0.08em)) solid var(--shape-color, currentColor);
  border-radius: 0.6em; /* Corner size; example value */
  corner-shape: bevel; /* round (default) | scoop | bevel | notch | square | squircle */
}

選擇技術

  • text-decoration (線條、墨水跳過、空格、內凹):線條應沿著文字延伸、與文字換行,並遵循字型指標。這是預設選項。
  • 含有 border-shape 和 clip-path 的虛擬元素:線條為自訂形狀,或需要繪製動畫。只有在 text-decoration-style 無法產生所需外觀時,才選擇這個選項,因為這需要額外定位。
  • background-clip: border-area:文字需要漸層外框。如果實心外框已足夠,請使用純色 border-color。
  • corner-shape:只有角落需要不同的形狀,例如斜角、凹角或缺角。搭配 border-radius 使用,即可設定圓角大小。
  • border-shape:外框需要 border-radius 無法繪製的形狀,例如標記或箭頭。如果只需要圓角,請使用 border-radius。
  • 內嵌懸停顯示:如果連結需要變更顏色以外的懸停功能,建議加入此效果。如果文字不具互動性,請略過。

備用策略

每個備用字型都能確保文字清晰易讀,並保留核心裝飾。請根據基準目標來判斷需求:如果目標包含不支援某項屬性的瀏覽器,請套用相應的備用方案。

墨水斷斷續續 all

如果基準目標不支援 text-decoration-skip-ink: all,系統會忽略宣告,瀏覽器則會保留 auto。拉丁文下緣仍會略過,只有 auto 未涵蓋的文字 (例如日文) 不會略過墨水。這是優雅降級,因此不需要額外的程式碼。

略過空格

如果基準目標不支援 text-decoration-skip-spaces,瀏覽器會使用預設的空格處理方式。這就是優雅降級:瀏覽器不會略過空格,而是在空格下方畫線。

插邊

如果基準目標不支援 text-decoration-inset,系統會忽略宣告,因此連結會保留正常、完全可見的底線,且轉場效果不會有任何動畫。這就是優雅降級:懸停顯示效果消失,連結仍有底線。

邊界區域

如果基準目標不支援 background-clip: border-area,系統會忽略宣告,並在整個邊框方塊下方繪製背景,填滿文字,而非僅填滿外框。如果漸層是設計的一部分,請「不要」只用單色邊框。請改為重現漸層,並僅在 @supports 內套用 background-clip: border-area。

如果元素的背景是已知的平面顏色,請使用不透明圖層。在邊框間距方塊上繪製表面顏色,並在邊框方塊下方繪製漸層,讓漸層只透過透明邊框顯示:

.ring {
  border: var(--ring-width, max(2px, 0.12em)) solid transparent; /* Example width */
  /* Fallback: an opaque surface layer hides the gradient inside the border. */
  background:
    linear-gradient(var(--surface, canvas) 0 100%) padding-box,
    linear-gradient(135deg, var(--ring-start, #2563eb), var(--ring-end, #db2777)) border-box;
}

@supports (background-clip: border-area) {
  .ring {
    background: linear-gradient(135deg, var(--ring-start, #2563eb), var(--ring-end, #db2777)) border-box;
    background-clip: border-area; /* The inside stays transparent */
  }
}

如果內部必須保持透明,請使用遮罩虛擬元素。虛擬元素會帶有漸層,而遮罩會剪除除了內邊距環以外的所有內容:

@supports not (background-clip: border-area) {
  .ring {
    position: relative;
    border-color: transparent;
  }

  .ring::before {
    content: "";
    position: absolute;
    inset: calc(var(--ring-width, max(2px, 0.12em)) * -1); /* Cover the border box */
    padding: var(--ring-width, max(2px, 0.12em)); /* Ring thickness */
    border-radius: inherit;
    background: linear-gradient(135deg, var(--ring-start, #2563eb), var(--ring-end, #db2777));
    /* Keep only the area between the content box and the border box. */
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    pointer-events: none;
  }
}

框線形狀

如果基準目標不支援 border-shape,系統會忽略宣告,元素也會保留矩形邊框。只有在形狀純粹是裝飾時,才可使用圓角矩形做為備用形狀。如果形狀具有意義 (例如標記、箭頭或圖說文字),請盡可能重現,並僅在 @supports 內套用 border-shape。

使用裁剪的虛擬元素繪製外框。保留透明邊框,讓方塊大小與支援的情況相同,並以輪廓顏色填滿虛擬元素,然後使用 evenodd clip-path 將其剪成環狀:外部形狀,然後是內嵌相同形狀,內嵌寬度為筆劃寬度。

.tag {
  --stroke: max(2px, 0.08em); /* Example stroke width */
  position: relative;
  padding: 0.2em 1.5em 0.2em 0.9em; /* Example values */
  border: var(--stroke) solid transparent;
}

@supports not (border-shape: circle(50%)) {
  .tag::before {
    content: "";
    position: absolute;
    inset: calc(var(--stroke) * -1); /* Cover the border box */
    background: var(--shape-color, currentColor);
    pointer-events: none;
    /* Outer shape, then the inner shape inset by the stroke. The inner points are approximate on
       diagonal edges, so the stroke looks slightly thinner there. */
    clip-path: polygon(evenodd,
      0 0, 85% 0, 100% 50%, 85% 100%, 0 100%,
      var(--stroke) var(--stroke),
      calc(85% - var(--stroke)) var(--stroke),
      calc(100% - var(--stroke) * 2) 50%,
      calc(85% - var(--stroke)) calc(100% - var(--stroke)),
      var(--stroke) calc(100% - var(--stroke)));
  }
}

@supports (border-shape: circle(50%)) {
  .tag {
    border-color: var(--shape-color, currentColor);
    border-shape: polygon(0 0, 85% 0, 100% 50%, 85% 100%, 0 100%);
  }
}

如要建立填滿的形狀而非外框,請直接在元素上放置具有相同點的 clip-path: polygon(...)。

如果是虛擬元素底線,請在 .draw::after 上使用 border-block-end 繪製直線,並僅在 @supports (border-shape: circle(50%)) 內套用 block-size、border 和 border-shape 波浪線。線條是直線,但仍會繪製,因為 clip-path 動畫不取決於形狀。

clip-path 也會裁剪焦點環和 box-shadow。請勿依賴裁剪元素的外框來顯示焦點,而是應透過變更顏色或背景來設定 :focus-visible 的樣式。