3288 字
16 分鐘

CSS 倒圓角 Tab Bar 實作:用量測與 transform 移動凹槽

最近開發時,剛好遇到一個帶倒圓角凹槽的 Tab Bar 設計。以前沒有實作過這種會跟著選中項目移動的曲線,因此我查了一下網路上通常怎麼處理,也順手把研究過程記錄下來。

一開始找到的兩個 CodePen,剛好代表兩種定位思路:一種會量測選中項目的實際位置,再移動凹槽;另一種則按照項目索引與固定欄寬計算座標。繼續查下去才發現,凹槽本身還能用偽元素、CSS mask、SVG path、clip-path,甚至較新的 corner-shapeborder-shape 處理。畫法不只兩種,只是「形狀怎麼畫」與「位置怎麼算」必須分開看。

因此才有這次的實作測試。我先整理不同形狀方案的限制,再拆解兩個 CodePen 的 HTML、CSS、JavaScript,最後做一份可執行原型,實際切換選中項目並測試窄版尺寸,確認哪一種定位方式真的可以成立。

先講結論:這次選 SVG path 控制曲線,並保留 alinjfz 範例的「量測選中項目,再用 transform 移動凹槽」架構;不要沿用 sanchodelniglo 範例的固定欄寬公式。之後即使把形狀換成 CSS mask,定位程式也不需要跟著重寫。

倒圓角凹槽的畫法不只兩種#

網路上的作法大致可以整理成以下幾類。這張表比較的是「如何產生凹槽形狀」,還沒有處理選中項目的位置。

畫法如何形成凹槽優點與限制這次的判斷
偽元素+box-shadow用左右兩個圓角偽元素或陰影拼出肩部程式短,但通常需要配合固定背景色;透明背景、陰影和接縫較難處理可當簡單造型,不採用
CSS mask+漸層radial-gradient()conic-gradient() 和 mask layer 挖出透明區域能用 CSS 變數控制半徑與深度,也能保留真正透明的凹槽;多層漸層公式較難閱讀可行的替代方案
SVG path/clipPath用貝茲曲線直接描述完整輪廓曲線控制直觀、瀏覽器支援成熟;路徑座標仍要自行設計這次採用
clip-path: shape()用 CSS shape command、百分比與變數描述曲線path() 更適合響應式尺寸;屬於 2026 年才達到 Baseline 的新功能,仍要考慮舊瀏覽器可作新版漸進增強
corner-shape: scoop將盒子的四個角改成內凹 superellipse邊框、背景與陰影會跟著凹角走,但只能處理盒子角落,不能單獨產生導覽列中間的移動凹槽不能獨立完成這次需求
border-shape<basic-shape> 定義整個元素外框理論上可直接描述完整 Tab Bar 輪廓,但目前仍是 Limited availability 的實驗功能先記錄,不作正式基礎

除了原本兩個範例,我另外找到 Temani Afif 的 Inverted border-radius using CSS mask。它用 radial-gradient()conic-gradient() 與 CSS 變數控制內凹半徑,證明不用 SVG 也能做出真正透明的倒圓角。mask-imagemask-composite 已有跨主要瀏覽器的 Baseline 支援,但複合順序和漸層停駐點仍需要實機測試。

較新的 shape() 則補上 path() 不容易響應式縮放的問題。MDN 的比較指出,path() 的座標固定為像素,而且 path 字串不能使用 var()shape() 可以使用百分比與 CSS 變數。不過這篇原型優先保留支援成熟、設計調整直接的 SVG path。

corner-shape: scoop 也確實能產生原生凹角,但它控制的是盒子四角;這次要移動的是 Tab Bar 上緣中央的一整段曲線,所以仍需要獨立形狀層。更自由的 border-shape 目前仍屬實驗功能,適合列入後續觀察,不適合取代正式版本。

無論最後用 mask、SVG 或新的 CSS shape,形狀層都不應自己猜目前是第幾欄。接下來兩個 CodePen 真正值得比較的,是它們如何算出形狀應該移到哪裡。

兩個 CodePen 真正的差異在定位方式#

比較項目alinjfz:Bucardo 動畫 Tab Barsanchodelniglo:Gooey Tab Bar
項目寬度flex-grow: 1,由實際版面決定每個連結固定 width: 120px
指示器定位讀取 getBoundingClientRect()使用索引乘上固定欄寬
移動方式translate3d()修改 left
尺寸變動resize 時重新量測沒有重新量測
形狀SVG clipPath 裁出曲線SVG gooey filter 合成水滴效果
適合借用的部分量測與移動架構視覺實驗,不適合作為響應式定位基礎

alinjfz 的核心計算可整理成三步:

  1. 取得選中按鈕相對於 viewport 的矩形。
  2. 扣掉 Tab Bar 的左側位置,再補上按鈕與凹槽寬度差的一半。
  3. 將結果交給 translate3d()

這樣算出來的不是「第幾格」,而是選中按鈕在當下版面中的真實中心。來源範例也會在視窗縮放時再次執行同一個計算。

sanchodelniglo 的 JavaScript 則直接使用這個關係:

const left = index * 120 + 60 - 45;

120 是每個項目的固定寬度,60 是項目中心,45 是 90px 指示器的一半。這個算式在原範例成立,因為 CSS 同時把連結寫死為 width: 120px。只要改成平均分欄、變更項目寬度或加入不同長度的文案,JavaScript 與 CSS 的隱含契約就會失效。

注意:alinjfz 適合借用的是架構,不是整份程式碼直接複製。它的編譯後 CSS 有多段重複的 .menu_color_2 選擇器,而且色彩、尺寸與 SVG 路徑都綁著原設計,實際專案仍應拆開整理。

倒圓角形狀與移動邏輯要分開#

原型把曲線放在獨立的 .tabbar__notch。這裡沿用 Bucardo 範例的連續曲線輪廓,但把高度壓到 24px,只讓圓形按鈕約三分之一露出導覽列;中央維持圓潤低凸,左右則用同一條 path 形成連續的倒圓弧,不用偽元素拼接。形狀本身不需要知道目前選中第幾項。

<nav class="tabbar" aria-label="主要導覽列">
<div class="tabbar__notch" aria-hidden="true">
<svg viewBox="0 0 202.9 45.5" preserveAspectRatio="none">
<path
fill="currentColor"
d="M6.7 45.5c5.7.1 14.1-.4 23.3-4 5.7-2.3 9.9-5 18.1-10.5 10.7-7.1 11.8-9.2 20.6-14.3 5-2.9 9.2-5.2 15.2-7 7.1-2.1 13.3-2.3 17.6-2.1 4.2-.2 10.5.1 17.6 2.1 6.1 1.8 10.2 4.1 15.2 7 8.8 5 9.9 7.1 20.6 14.3 8.3 5.5 12.4 8.2 18.1 10.5 9.2 3.6 17.6 4.2 23.3 4H6.7Z"
/>
</svg>
</div>
<button class="tabbar__item" aria-selected="true">首頁</button>
<button class="tabbar__item" aria-selected="false">作品與研究</button>
<button class="tabbar__item" aria-selected="false">最新消息</button>
<button class="tabbar__item" aria-selected="false">關於我</button>
</nav>

凹槽使用絕對定位,平常只改 transformwill-change 不要套在整頁,只標記確定會移動的凹槽即可。

.tabbar {
--notch-width: 108px;
--notch-rise: 24px;
position: relative;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.tabbar__notch {
position: absolute;
top: calc(var(--notch-rise) * -1);
left: 0;
width: var(--notch-width);
height: var(--notch-rise);
color: #1c2533;
transform: translate3d(0, 0, 0);
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.tabbar__notch svg {
display: block;
width: 100%;
height: 100%;
}
@media (prefers-reduced-motion: reduce) {
.tabbar__notch {
transition: none;
}
}

如果設計稿需要更銳利或更深的凹槽,只要修改 path。若改用 CSS mask,也只替換這一層;getBoundingClientRect()translate3d() 的定位程式不需要重寫。CSS masking 可以接受漸層或 SVG mask,但是否值得改用 mask,應依背景透明需求與目標瀏覽器測試結果決定。

用實際中心計算 transform 位移#

以下是這次實測使用的核心函式:

const tabbar = document.querySelector('.tabbar');
const notch = tabbar.querySelector('.tabbar__notch');
function moveNotchTo(item) {
const barRect = tabbar.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
const notchWidth = notch.offsetWidth;
const x =
itemRect.left -
barRect.left +
itemRect.width / 2 -
notchWidth / 2;
notch.style.transform = `translate3d(${x}px, 0, 0)`;
}

公式的目的只有一個:讓「按鈕中心」等於「凹槽中心」。它不需要知道欄數,也不假設每格都是 120px。

按鈕中心 = item.left - bar.left + item.width / 2
凹槽左側 = 按鈕中心 - notch.width / 2

這份原型的 Tab Bar 沒有 border。如果實際元件有 border,絕對定位的 containing block 與 getBoundingClientRect() 的 border box 會差一個邊框寬度,計算時要再扣掉 tabbar.clientLeft

這個作法與 FLIP 並不完全相同,但都把動畫位移交給 transform。若要繼續整理版面量測與 transform 的關係,可以搭配站內的 FLIP 動畫優化技術 一起看;不要因此延伸成「所有 transform 都不會觸發繪製」這種過度簡化的結論。

ResizeObserver 比只聽 window resize 更完整#

原 CodePen 在 window.resize 時重新定位,已經比完全不處理尺寸變化可靠。不過元件寬度還可能因側欄開合、父層 grid 改變、字型載入或文案更新而變動,這些情況不一定伴隨 window resize。

原型改用 ResizeObserver 觀察 Tab Bar 與項目,再以 requestAnimationFrame() 合併同一幀的更新:

let activeItem = tabbar.querySelector('[aria-selected="true"]');
let frame = 0;
function scheduleMove() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => moveNotchTo(activeItem));
}
const resizeObserver = new ResizeObserver(scheduleMove);
resizeObserver.observe(tabbar);
tabbar.querySelectorAll('.tabbar__item').forEach((item) => {
resizeObserver.observe(item);
item.addEventListener('click', () => {
activeItem.setAttribute('aria-selected', 'false');
item.setAttribute('aria-selected', 'true');
activeItem = item;
moveNotchTo(activeItem);
});
});
document.fonts?.ready.then(scheduleMove);

這段 callback 只修改 transform,不會改變被觀察元素的寬高。若 callback 內同時修改尺寸,就要避免 ResizeObserver 反覆觸發自身。

實測結果:四個項目與窄版都能對齊#

測試日期為 2026-07-21,環境為 macOS 的 Chrome。驗證不是只看綠色 PASS,而是在 420ms transition 結束後,分別讀取按鈕與凹槽的 getBoundingClientRect(),比較兩者中心位置。

測試條件Tab Bar 寬度選中項目動畫結束後中心誤差結果
桌面版854px首頁0.000px通過
桌面版854px作品與研究0.000px通過
桌面版854px最新消息0.000px通過
桌面版854px關於我0.000px通過
390px viewport316px作品與研究0.000px通過

測試過程也發現第一版雖然凹槽對得準,程式碼區塊的 intrinsic width 卻會把窄版卡片撐出 viewport。替外層與程式碼容器補上 min-width: 0,並讓程式碼區塊自行橫向捲動後,Tab Bar 才真正縮到 316px。這個問題與凹槽公式無關,但如果只點按鈕、不檢查整體寬度,很容易漏掉。

桌面版依序切換四個項目後,畫面與量測值如下:

桌面版倒圓角 Tab Bar 切換到作品與研究,圓形凹槽目標中心誤差為 0.000px

窄版選擇較長的「作品與研究」文案後,凹槽仍以實際中心重新定位:

390px viewport 的倒圓角 Tab Bar,選中作品與研究後中心誤差為 0.000px

瀏覽器 Console 沒有錯誤。實際專案的自動化測試仍建議使用 0.5px 左右的容許值,避免不同 device pixel ratio 與次像素取整造成不必要的失敗。

實際套進專案前還要補的部分#

這份原型證明定位與響應式計算可以成立,但正式元件仍有幾件事要依產品語意處理:

  • 如果它是頁面導覽,應使用 <a>aria-current="page",不要為了造型硬套 tab 語意。
  • 如果它真的是 tab,除了 role="tab"aria-selected,還要補左右方向鍵、焦點管理與對應的 tabpanel
  • 初次定位應先關閉 transition,量測完成後再加入動畫 class,避免頁面載入時凹槽從最左側滑進來。
  • 切換期間不要用當下的 transform 矩形判定失敗;測試要等待 transitionend,或比較計算出的目標中心。
  • 形狀、定位與選中狀態分三層處理,之後換 SVG path 或 CSS mask 才不會連互動程式一起改。

最後保留的一句話是:倒圓角的畫法可以換,但移動式凹槽要以「實際量測 + transform」為骨架,避開把欄寬藏在索引公式裡。

常見問題#

Q: CSS 的 border-radius 可以直接做倒圓角嗎?#

A: 不行。border-radius 處理的是盒子外側的凸圓角,無法在邊緣中央直接挖出內凹曲線。這類形狀通常要搭配 CSS mask、clip-path、SVG path,或以偽元素拼接;corner-shape: scoop 雖能做凹角,但控制的是盒子四角,而且仍要確認目標瀏覽器支援。

Q: SVG path 與 CSS mask 應該怎麼選?#

A: 需要精準對照設計稿、反覆調整貝茲曲線時,SVG path 會比較直觀;如果凹槽主要由圓弧組成,並希望用 CSS 變數控制半徑、深度或透明挖孔,CSS mask 會更合適。兩者只負責形狀,移動時都能沿用同一套量測與 transform 邏輯。

Q: Tab Bar 已經平均分欄,還需要 getBoundingClientRect() 嗎?#

A: 如果欄數、容器寬度與每格尺寸永遠固定,確實可以從索引推算位置;但這等於讓 JavaScript 依賴 CSS 的欄寬契約。實際量測可以直接取得目前中心,面對不等寬項目、側欄開合、字型載入或文案變動時,不需要另外同步座標公式。

Q: corner-shape: scoop 能取代目前的 SVG 凹槽嗎?#

A: 目前不能直接取代。corner-shape 改變的是元素四個角的曲率,適合卡片或區塊邊角,無法單獨描述 Tab Bar 上緣中央這段會移動的凹槽。較自由的 border-shape 理論上能畫完整外框,但仍屬實驗功能,因此這次只列入後續觀察。

參考資料:

alinjfz:Navbar Bottom Mobile

sanchodelniglo:Gooey Navbar

Temani Afif:Inverted border-radius using CSS mask

MDN:Element.getBoundingClientRect()

MDN:Resize Observer API

MDN:CSS masking

MDN:mask-composite

MDN:clip-path 與 shape()、path() 比較

MDN:corner-shape

MDN:border-shape

CSS 倒圓角 Tab Bar 實作:用量測與 transform 移動凹槽
https://laplusda.com/posts/css-inverse-border-radius-tab-bar/
作者
Zero
發佈於
2026-07-21
許可協議
CC BY-NC-SA 4.0
這篇文章有幫助嗎?

回報錯字、失效連結,或告訴我你想看的延伸主題。