CSS 倒圓角 Tab Bar 實作:用量測與 transform 移動凹槽
最近開發時,剛好遇到一個帶倒圓角凹槽的 Tab Bar 設計。以前沒有實作過這種會跟著選中項目移動的曲線,因此我查了一下網路上通常怎麼處理,也順手把研究過程記錄下來。
一開始找到的兩個 CodePen,剛好代表兩種定位思路:一種會量測選中項目的實際位置,再移動凹槽;另一種則按照項目索引與固定欄寬計算座標。繼續查下去才發現,凹槽本身還能用偽元素、CSS mask、SVG path、clip-path,甚至較新的 corner-shape 與 border-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-image 與 mask-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 Bar | sanchodelniglo:Gooey Tab Bar |
|---|---|---|
| 項目寬度 | flex-grow: 1,由實際版面決定 | 每個連結固定 width: 120px |
| 指示器定位 | 讀取 getBoundingClientRect() | 使用索引乘上固定欄寬 |
| 移動方式 | translate3d() | 修改 left |
| 尺寸變動 | resize 時重新量測 | 沒有重新量測 |
| 形狀 | SVG clipPath 裁出曲線 | SVG gooey filter 合成水滴效果 |
| 適合借用的部分 | 量測與移動架構 | 視覺實驗,不適合作為響應式定位基礎 |
alinjfz 的核心計算可整理成三步:
- 取得選中按鈕相對於 viewport 的矩形。
- 扣掉 Tab Bar 的左側位置,再補上按鈕與凹槽寬度差的一半。
- 將結果交給
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>凹槽使用絕對定位,平常只改 transform。will-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 viewport | 316px | 作品與研究 | 0.000px | 通過 |
測試過程也發現第一版雖然凹槽對得準,程式碼區塊的 intrinsic width 卻會把窄版卡片撐出 viewport。替外層與程式碼容器補上 min-width: 0,並讓程式碼區塊自行橫向捲動後,Tab Bar 才真正縮到 316px。這個問題與凹槽公式無關,但如果只點按鈕、不檢查整體寬度,很容易漏掉。
桌面版依序切換四個項目後,畫面與量測值如下:

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

瀏覽器 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 理論上能畫完整外框,但仍屬實驗功能,因此這次只列入後續觀察。
參考資料:
Temani Afif:Inverted border-radius using CSS mask
MDN:Element.getBoundingClientRect()
回報錯字、失效連結,或告訴我你想看的延伸主題。