iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
自我挑戰組

賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁系列 第 24 篇

Day 24:惡搞病歷戳章動畫:用 CSS 3D 變形與落下特效模擬真實診所

  • 分享至 

  • xImage
  •  

前言

昨天把多語系的破洞補完之後,我把結果頁翻來覆去檢查了好幾次,確定每個角落都能正常切換語言,心裡踏實不少。

然後我盯著那張乾乾淨淨的診斷結果看了很久,總覺得哪裡怪怪的。

雷達圖有了,六個維度一目了然,電池耗損率也會跳動,連文案都能切四種語言……但整張結果頁安安靜靜地躺在那裡,像一份還沒批准的公文。

真正的診所不是這樣的。

去診所拿藥單,醫生寫完字,最後一定會「啪」一聲蓋個章,那個章通常還有點歪,印泥也不是很均勻,但就是因為這樣才顯得真實。沒蓋章的診斷書,總覺得少了那麼一點公信力(即使我們這份診斷本來就毫無公信力可言)。

所以今天要做的事我非常期待:

就是讓一枚戳章從畫面上方重重落下,帶點旋轉跟晃動,狠狠蓋在診斷結果上。


核心觀念:CSS 的 3D 變形,不是真的立體,是騙眼睛

一開始聽到「CSS 3D」,我以為要處理什麼複雜的透視矩陣。後來查了一下才發現,瀏覽器幫我們把這些數學都包好了,剩下要做的只是調幾個關鍵字。

整個效果靠的是兩個東西:

第一個是 perspective。 這個屬性加在父層容器上,告訴瀏覽器「你要用多近的距離去看這個 3D 空間」。數字越小,透視感越誇張,物體靠近或遠離的時候變化越劇烈;數字越大,看起來越接近平面。

.stamp-stage {
    perspective: 600px;
}

第二個是 transform 裡的 3D 函式。 平常比較常用的是 translateX、scale 這種二維的操作,但加上 Z 之後,就能讓元素往螢幕外跑或往螢幕裡縮:

transform: translateZ(-300px) rotateX(40deg);

translateZ(-300px) 代表元素一開始離鏡頭比較遠(感覺比較小、比較高);rotateX() 則是讓它繞著水平軸轉,模擬「從高處往下壓」的那種傾斜感。

掉落動畫要做的事情,說穿了就是讓這兩個數值隨著時間變化:一開始高高在上、帶點傾斜,最後砸到正常位置、角度轉正,中間再補一點點回彈,看起來才像真的有重量摔下來,不是單純往下滑。

狀態 1:translateZ(-300px) rotateX(40deg) rotate(-15deg)  ← 懸在半空、傾斜著
            ↓
狀態 2:translateZ(0) rotateX(0deg) rotate(8deg) scale(1.1)  ← 落地瞬間,稍微過衝
            ↓
狀態 3:translateZ(0) rotateX(0deg) rotate(-5deg) scale(1)   ← 彈一下回穩

這種「超過目標值再彈回來」的手法,就是俗稱的 overshoot,CSS 動畫裡常常拿來模擬現實世界的慣性跟彈性,比起平滑地滑到定位,看起來更有「東西真的砸下來」的力道。


動手寫 Code:惡搞病歷戳章

今天的示範把診斷結果跟戳章放在同一個畫面,點按鈕觸發蓋章動畫:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:惡搞病歷戳章</title>
<style>
    body {
        font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
        background: #e3f2fd;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        margin: 0;
        padding: 20px;
    }
    .card {
        background: #fdfdfd;
        padding: 30px;
        border-radius: 12px;
        box-shadow: 0 12px 30px rgba(0,0,0,0.15);
        width: 100%;
        max-width: 480px;
        position: relative;
        overflow: hidden;
    }
    h2 { color: #1e88e5; margin: 0 0 15px; text-align: center; }

    .report {
        border: 1px dashed #b0bec5;
        border-radius: 8px;
        padding: 16px;
        font-size: 0.9rem;
        color: #37474f;
        line-height: 1.8;
    }
    .report b { color: #1e88e5; }

    /* 戳章需要一個 3D 舞台,才能用 translateZ / rotateX */
    .stamp-stage {
        position: relative;
        height: 0;
        perspective: 600px;
    }

    .stamp {
        position: absolute;
        top: -40px;
        right: 10px;
        width: 110px;
        height: 110px;
        border: 6px solid #d32f2f;
        border-radius: 50%;
        color: #d32f2f;
        font-weight: bold;
        font-size: 0.85rem;
        display: flex;
        align-items: center;
        justify-content: center;
        text-align: center;
        transform-style: preserve-3d;
        opacity: 0;
        pointer-events: none;
        transform: translateZ(-300px) rotateX(40deg) rotate(-15deg);
    }

    /* 落下的過程:高懸傾斜 → 落地過衝 → 彈回定位 */
    .stamp.drop {
        animation: stampFall 0.7s cubic-bezier(0.3, 0.6, 0.3, 1) forwards;
    }

    @keyframes stampFall {
        0% {
            opacity: 0;
            transform: translateZ(-300px) rotateX(40deg) rotate(-15deg);
        }
        55% {
            opacity: 1;
            transform: translateZ(0) rotateX(0deg) rotate(8deg) scale(1.15);
        }
        75% {
            transform: translateZ(0) rotateX(0deg) rotate(-5deg) scale(0.95);
        }
        100% {
            opacity: 1;
            transform: translateZ(0) rotateX(0deg) rotate(-8deg) scale(1);
        }
    }

    /* 印泥不均勻的質感,用兩層陰影疊出斑駁感 */
    .stamp::after {
        content: "";
        position: absolute;
        inset: 0;
        border-radius: 50%;
        box-shadow:
            inset 0 0 0 2px rgba(211, 47, 47, 0.3),
            inset 4px 4px 8px rgba(255,255,255,0.4),
            inset -4px -4px 8px rgba(211,47,47,0.15);
        pointer-events: none;
    }

    .btn {
        background-color: #1e88e5;
        color: white;
        border: none;
        border-radius: 50px;
        padding: 12px 30px;
        font-size: 1rem;
        cursor: pointer;
        width: 100%;
        margin-top: 20px;
        transition: 0.2s;
    }
    .btn:hover { background-color: #1565c0; transform: translateY(-2px); }
</style>
</head>
<body>

    <div class="card">
        <h2>🏥 賽博病歷診斷報告</h2>

        <div class="report">
            今日綜合腦內電池耗損率:<b>62%</b><br>
            最需要留意的維度:<b>心態</b><br>
            建議:對著鏡子大喊三聲「我已經很努力了」,然後去睡覺。
        </div>

        <div class="stamp-stage">
            <div class="stamp" id="stamp">賽博紓壓<br>診所認證</div>
        </div>

        <button class="btn" onclick="dropStamp()">蓋章確認</button>
    </div>

    <script>
        function dropStamp() {
            const stamp = document.getElementById('stamp');

            // 移除再加回 class,確保重複點擊時動畫能重新觸發
            stamp.classList.remove('drop');
            void stamp.offsetWidth; // 強制瀏覽器重新計算一次樣式,動畫才會重跑
            stamp.classList.add('drop');
        }
    </script>
</body>
</html>

再存成 .html 打開,點幾次「蓋章確認」,章會從右上角高處砸下來,帶著一點旋轉跟過衝的彈性感,最後穩穩停在報告旁邊,有點歪,剛剛好。


踩過的小細節

  • transform-style: preserve-3d 忘了加,整個效果直接變平的
    一開始沒加這個屬性,rotateX() 雖然數值有在變,畫面上看起來卻只是單純地變矮變扁,完全沒有那種「從遠處飛過來」的縱深感。加上 preserve-3d 之後,瀏覽器才會真的把這個元素當成 3D 空間裡的物件去算透視,而不是硬壓扁成平面。

  • void stamp.offsetWidth 這行看起來很詭異,到底在幹嘛
    如果使用者連續點好幾次「蓋章確認」,單純 classList.add('drop') 在第二次點擊時完全沒反應,因為瀏覽器發現 class 沒有真的被移除又加回去,就偷懶跳過重新觸發動畫這一步。中間插一行讀取 offsetWidth(隨便讀一個會強迫瀏覽器重新計算版面的屬性),等於逼瀏覽器先把「拿掉 class」這件事確實處理完,動畫才會每次點擊都乾乾淨淨地重跑一次。

  • cubic-bezier(0.3, 0.6, 0.3, 1) 這組數字是哪來的
    老實說是慢慢調出來的,不是算出來的。目標是讓前段加速感強一點(模擬重力往下墜),後段緩下來不要太突兀。比起直接用瀏覽器內建的 ease-out,自己調的貝茲曲線墜落感更明顯一點,但差異真的要仔細比對才看得出來,算是我自己的小小堅持。

  • 印泥的斑駁感,是靠疊兩層 inset 陰影做出來的
    一開始章蓋上去是一個很乾淨工整的圓圈,看起來太新、太假。在 ::after 偽元素裡疊上一層偏亮跟一層偏暗的內陰影,模擬印泥壓下去深淺不均的手感,雖然只是幾行 CSS,視覺上的說服力差很多。


開發心得與設計考量

  • 3D 效果其實看似平凡,但難就難在「調出會呼吸的動畫」
    perspective 跟 translateZ 這些語法本身學起來很快,真正花時間的是中間那段 overshoot 的節奏:落下多快、過衝多少、回彈幾次,全部都是反覆點按鈕、肉眼比對調出來的。CSS 動畫這種東西沒辦法用邏輯推導出「正確答案」,只能靠感覺一點一點磨。

  • 一個很小的細節,卻決定了整個效果的說服力
    一開始我用的是純色邊框圓圈,蓋上去看起來就是個普通的印章貼紙。等加了印泥的斑駁陰影之後,整體說服力一口氣提升不少。這讓我想到之前做心電圖的時候也有類似的心得:核心邏輯往往沒有很複雜,真正讓東西「看起來對」的,常常是一些看似無關緊要的小裝飾。

  • 這次意外地沒有牽扯到計分或語言系統
    做了這麼多篇,難得今天這個功能完全獨立,不需要接 userScores,也不用管 i18n(頂多戳章上的文字之後要記得補翻譯)。單純是一個視覺效果,反而讓我可以把全部心力放在「調動畫曲線」這件小事上,算是這個階段少見的輕鬆一天。


今日結語與明日預告

今天我們用 perspective 搭配 translateZ、rotateX,刻出了一枚會從天而降、帶點彈性晃動的惡搞病歷戳章,讓診斷結果終於有了那麼一點「蓋章認證」的儀式感,雖然認證的內容完全不正經。

結果頁現在已經很完整了:雷達圖、處方籤、電池耗損率、戳章,一應俱全。

但我開始覺得,光是「自己一個人看完就結束」好像有點可惜。

壓力這種東西,一個人扛總是比較悶,如果能讓大家把今天的怨言丟出來,看看別人也在為了類似的鳥事崩潰,會不會反而比較療癒?

所以明天 Day 25,我們要做一面即時匿名發洩牆:用前端陣列搭配 DOM 操作,做一個簡單的留言板,讓使用者可以匿名貼上今天的厭世發言,跟著其他人一起看看大家都在為什麼而崩潰。

明天,讓診所多開一面大家互吐苦水的牆啦!


上一篇
Day 23:雷達圖 RWD 與動態字體對應:適應日夜模式與多語系標籤
下一篇
Day 25:開一面牆,讓大家一起吐苦水!
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言