ホバーで剥がせるシール(Sticker Peel)をCSS変数と@propertyだけで実装する
ホバーするとシールがペロッとめくれる「Sticker Peel」と呼ばれる演出を、CSS変数と@propertyだけで実装する手法を紹介します。GSAPのようなアニメーションライブラリは不要で、JavaScriptも一切使いません。
ポイントは、--peel-progress(0〜1の進捗)と--peel-direction(めくれる角度)という2つのCSS変数だけでめくれ量と方向を制御し、clip-pathとtranslateYをcalc()で連動させるところにあります。
デモ(単体HTML)
まずは完成形から見てみましょう。下のシールにホバー(タップ)すると、上端からめくれます。
このデモは、以下のHTMLをそのまま.htmlファイルとして保存するだけで動作する自己完結型のコードです。
<!DOCTYPE html><html lang="ja"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>Sticker Peel Hover Demo</title> <style> @property --peel-progress { syntax: "<number>"; inherits: true; initial-value: 0; } @property --peel-direction { syntax: "<angle>"; inherits: true; initial-value: 145deg; }
* { box-sizing: border-box; }
/* 回転した子要素の透明なバウンディングボックスによる不要な縦スクロールを抑制する。 シールの drop-shadow ツリー(.sticker)の外側でクリップすることで、 filter: drop-shadow と overflow を同一ツリーに同居させない (Safari で影が四角く出るのを避けるため)。 ビューポートのスクロールは html の overflow で決まるため body ではなく html に指定する */ html { overflow: hidden; }
body { display: grid; place-items: center; min-height: 100dvh; margin: 0; background: #f4ede0; font-family: system-ui, sans-serif; color: #3a2e25; }
.demo { display: grid; gap: 1.5rem; place-items: center; text-align: center; }
.demo p { margin: 0; font-size: 14px; color: #80695a; }
.sticker { --w: 220px; --sticker-peelback-max: 40%; --sticker-p: 4px; --sticker-start: calc(-1 * var(--sticker-p)); --sticker-end: calc(100% + var(--sticker-p)); --sticker-peelback: calc(var(--peel-progress) * var(--sticker-peelback-max));
--peel-progress: 0; --peel-direction: 145deg;
position: relative; width: var(--w); height: var(--w); padding: 0; cursor: pointer; background: transparent; border: 0; transition: --peel-progress 0.6s cubic-bezier(0.22, 1, 0.36, 1), --peel-direction 0.6s cubic-bezier(0.22, 1, 0.36, 1); filter: drop-shadow(6px 10px 14px rgba(0, 0, 0, 0.25)); }
.sticker:hover, .sticker:focus-visible { --peel-progress: 1; --peel-direction: 155deg; outline: none; }
.sticker-container { display: block; position: relative; width: 100%; height: 100%; transform: rotate(var(--peel-direction)); transform-origin: center; }
.sticker-art { width: 100%; height: 100%; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #ff8a8a, #c41e3a 70%); display: grid; place-items: center; font-size: 38px; font-weight: 900; color: #fff8e0; letter-spacing: 4px; /* 親コンテナの回転を打ち消して絵柄を水平に保つ */ transform: rotate(calc(-1 * var(--peel-direction))); user-select: none; }
.sticker-main { position: absolute; inset: 0; clip-path: polygon( var(--sticker-start) var(--sticker-peelback), var(--sticker-end) var(--sticker-peelback), var(--sticker-end) var(--sticker-end), var(--sticker-start) var(--sticker-end) ); }
.sticker-flap { position: absolute; top: -100%; left: 0; width: 100%; height: 100%; clip-path: polygon( var(--sticker-start) var(--sticker-start), var(--sticker-end) var(--sticker-start), var(--sticker-end) var(--sticker-peelback), var(--sticker-start) var(--sticker-peelback) ); transform: translateY(calc(2 * var(--sticker-peelback) - 1px)) scaleY(-1); }
/* めくれた裏側は暗くして紙の裏面を表現 */ .sticker-flap .sticker-art { filter: brightness(0.6) saturate(0.7); } </style> </head> <body> <div class="demo"> <button class="sticker" type="button" aria-label="シールをめくる"> <span class="sticker-container"> <span class="sticker-main"> <span class="sticker-art">PEEL</span> </span> <span class="sticker-flap" aria-hidden="true"> <span class="sticker-art">PEEL</span> </span> </span> </button> <p>シールにホバーするとめくれます</p> </div> </body></html>
ここから先は、このコードがどう組み立てられているのかを順番に分解していきます。
実装のポイント
1. 構造
DOMの構造は次のようになっています。
.sticker // hover で CSS 変数を変えるトリガー└─ .sticker-container // peel-direction で rotate ├─ .sticker-main // シール本体。上端から clip-path で削れる │ └─ .sticker-art // 絵柄。container の回転を打ち消して水平を保つ └─ .sticker-flap // 折り返された部分 └─ .sticker-art // 同じ絵柄 (filter で裏面風に着色)
シール本体である.sticker-mainと、めくれて折り返された部分である.sticker-flapという2つの要素を重ねているのが基本構造です。どちらも同じ.sticker-art(絵柄)を内側に持ち、.sticker-flap側だけfilterで暗く着色することで、紙の裏面に見せています。
なお、トリガーには<button>要素を使い、aria-labelを添えています。ホバーだけでなく:focus-visibleでも反応するようにしているため、キーボード操作でもめくれが発火します。
2. 単一の進捗変数で2要素を連動させる
このテクニックの肝は、--peel-progressという1つの変数を0から1へ動かすだけで、calc()が以下の2か所に同時に作用する点です。
| 適用先 | 計算 | 効果 |
|---|---|---|
.sticker-mainのclip-path上端Y座標 | var(--peel-progress) * var(--sticker-peelback-max) | シール本体が上から削れる |
.sticker-flapのtranslateY | 2 * var(--sticker-peelback) - 1px | 折り返し部分が上から降りてくる |
.sticker-mainは上端からclip-pathで徐々に切り取られていき、その切り取られた分だけ.sticker-flapが上から降りてきます。
clip-pathの「削れた上端」と.sticker-flapの「降りてくる下端」が幾何的に常に一致するため、繋ぎ目なくめくれた折り返しが連続して見えます。1つの変数で2要素を駆動しているので、両者がズレることがありません。
translateYが2 *になっているのは、.sticker-flapがtop: -100%の位置からscaleY(-1)で上下反転して降りてくるためです。見かけの折り返し量を作るには、進捗の2倍だけ動かす必要があります。末尾の- 1pxは、本体と折り返しの境界に隙間が出ないようにするための微調整です。
3. めくれの方向
.sticker-containerをrotate(var(--peel-direction))で傾けると、めくれの軸ごと斜めに回ります。一方で、中の.sticker-artはrotate(calc(-1 * var(--peel-direction)))で逆回転させ、コンテナの回転を打ち消すことで絵柄を水平に保っています。
.sticker-container { transform: rotate(var(--peel-direction));}
.sticker-art { /* 親コンテナの回転を打ち消して絵柄を水平に保つ */ transform: rotate(calc(-1 * var(--peel-direction)));}
ホバー時に角度を145deg → 155degのように微小に動かすと、紙が一瞬「迷う」ような硬めの質感が出ます。固定のままでも演出としては成立するので、ここは好みで調整できます。
4. @propertyでCSSトランジションを効かせる
CSS変数をそのままトランジションさせようとしても、通常は補間されず、ホバーした瞬間に終点へスナップしてしまいます。これは、未登録のCSS変数(カスタムプロパティ)の型が不定で、ブラウザが中間値を計算できないためです。
そこで@propertyを使い、変数の型を明示的に登録します。
@property --peel-progress { syntax: "<number>"; inherits: true; initial-value: 0;}
syntax: "<number>"と型を宣言することで、ブラウザが--peel-progressを数値として補間できるようになり、transition: --peel-progress 0.6s easeが効くようになります。角度を扱う--peel-directionの方はsyntax: "<angle>"で登録します。これでJavaScriptは完全に不要になります。
5. ブラウザ対応とフォールバック
@propertyの対応状況は以下の通りです。
- 対応ブラウザ: Chrome 85+ / Safari 16.4+ / Firefox 128+(Baseline 2024)
- 非対応環境: transitionがスナップ動作になり、めくれが瞬間的に切り替わる(演出は失われるが、機能としては破綻しない)
非対応環境でもアニメーションを担保したい場合は、requestAnimationFrameやGSAPで--peel-progressを直接トゥイーンする方法があります。
gsap.to(sticker, { '--peel-progress': 1, duration: 0.6, ease: 'power2.out' })
このようにJavaScript側から変数を更新する場合でも、めくれのロジック自体はCSSのcalc()に閉じているため、トゥイーンする対象は--peel-progressの1つだけで済みます。
一般化のコツ
--peel-progressを中心に据えた設計なので、応用の幅も広く取れます。
- 形状を変える:
.sticker-artをSVG・画像・テキストに差し替えるだけでOKです。clip-pathは親の矩形に対して動くので、中身が何であっても影響しません - めくれ方向を変える:
--peel-directionを45degや225degなどにすれば、4隅どこからでもめくれるようになります - 完全に剥がす:
--peel-progressを1以上まで持っていき、.sticker-mainをopacity: 0までフェードさせれば、「シールが剥がれて消える」演出になります - インタラクションを変える: hover / click toggle / scroll progress / pointer drag(GSAP Draggable)など、
--peel-progressを更新する手段は何でも組み合わせられます
いずれのパターンでも、変更するのは「--peel-progressをどう更新するか」だけです。めくれの見た目を作る計算はCSS側に集約されているため、ロジックを散らかさずに拡張できます。
実装でハマりやすいポイント
このパターンを実装するときに注意したいポイントを3つ挙げます。いずれも「動かない・崩れる」原因になりやすい割に、気付きにくいものです。
span には display: block が必要
.sticker-containerや.sticker-mainを<span>で組んでいますが、<span>はデフォルトがdisplay: inlineなので、width: 100% / height: 100%が効きません。コンテナの高さが0に潰れ、絶対配置した.sticker-mainのinset: 0も解決先を失って、シールがまったく表示されないという状態になります。
.sticker-container { display: block; /* これがないと width/height: 100% が無視される */ /* ... */}
<div>なら標準でblockですが、<button>の中身をインライン要素で組むときなどは見落としやすいので注意してください。
回転した要素のはみ出しで縦スクロールが出る
.sticker-containerをrotate()しているため、要素の軸平行バウンディングボックスが回転前より大きくなります。220pxの正方形を斜めに回すと、見た目の円は220pxに収まっていても、ボックスの外接矩形は約306pxまで広がります。この透明な四隅が下方向にはみ出して、不要な縦スクロールを生みます。
見えている円は範囲内なので、はみ出しているのは「空っぽの角」だけです。ルート要素側でクリップしてしまうのが手軽です。
html { overflow: hidden;}
なお、ビューポートのスクロールはhtml(ルート要素)のoverflowで決まるため、bodyに指定しても効かないことがあります。後述のSafariの挙動もあるため、クリップはhtml側に置くのが無難です。
Safari: filter: drop-shadowとoverflow: clipを同じツリーに同居させない
前項のスクロール対策として、シール要素自身にoverflow: clipを付けたくなります。しかしChromeでは問題なくても、Safariではシールの影が四角く描画されることがあります。
/* NG: Safari で影が四角くなる */.sticker { filter: drop-shadow(6px 10px 14px rgba(0, 0, 0, 0.25)); overflow: clip; /* ← drop-shadow と同居している */}
filter: drop-shadow()の対象ツリー内にoverflow: clip(やhidden)があると、Safariは影をアルファ形状(円)ではなくクリップ矩形(四角)から生成してしまうのが原因です。
対策は、クリップをdrop-shadowのツリーの外側に出すこと。影は.stickerに残したまま、スクロール対策のクリップを先ほどのhtml側へ寄せます。htmlは.stickerの祖先なので、影は.stickerの段階で円形に確定し、そのあとページ全体がhtmlでクリップされるだけになります。
/* OK: 影とクリップを別のツリーに分離する */html { overflow: hidden; /* スクロール抑制はルート側で行う */}
.sticker { filter: drop-shadow(6px 10px 14px rgba(0, 0, 0, 0.25)); /* clip は持たせない */}
「filter: drop-shadowを使う要素の内側(自分自身を含む)にはoverflow: clip / hiddenを置かない」と覚えておくと安全です。
まとめ
- Sticker Peelは
--peel-progressと--peel-directionの2変数だけで、めくれ量と方向を制御できる clip-pathの削れた上端と.sticker-flapの降りてくる下端をcalc()で連動させることで、繋ぎ目のないめくれを表現する@propertyでCSS変数の型を登録すると、変数そのものをtransitionで補間できるようになり、JavaScript不要でアニメーションが成立する- 進捗を1変数に集約しているため、インタラクションや形状の差し替えにも柔軟に応用できる
spanのdisplay: block、回転要素のはみ出しによる縦スクロール、Safariのdrop-shadow×overflowなど、組むうえでのハマりどころには注意する
@propertyはBaseline 2024に到達しており、こうした「数値の進捗をCSSだけで補間する」表現がだいぶ書きやすくなりました。シールのめくれに限らず、進捗で駆動するアニメーション全般に応用が効くパターンなので、引き出しとして持っておくと便利です。