DEMO
タブを切り替えるとバーが伸びて追従します
CODE
<div class="demo-stage eti-stage">
<div class="eti-tabs" id="eti-tabs" role="tablist" aria-label="Elastic Tab Indicator デモ">
<button class="eti-tab is-active" type="button" role="tab" aria-selected="true">OVERVIEW</button>
<button class="eti-tab" type="button" role="tab" aria-selected="false">FEATURES</button>
<button class="eti-tab" type="button" role="tab" aria-selected="false">PRICING</button>
<button class="eti-tab" type="button" role="tab" aria-selected="false">FAQ</button>
<span class="eti-ind" aria-hidden="true"></span>
</div>
<p class="eti-note">タブを切り替えるとバーが伸びて追従します</p>
</div>
.eti-stage { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1.6rem; padding:3rem 1rem; background:#0B0B10; min-height:280px; }
.eti-tabs {
position:relative; display:flex; gap:.2rem; flex-wrap:wrap; justify-content:center;
padding:.4rem; border-radius:999px;
background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.1);
}
.eti-tab {
position:relative; z-index:1; padding:.6rem 1.2rem; border:none; border-radius:999px;
background:transparent; color:#8A8A95; cursor:pointer;
font-family:'Oswald',sans-serif; font-size:.7rem; letter-spacing:.16em;
transition: color .28s ease;
}
.eti-tab.is-active { color:#fff; }
.eti-tab:focus-visible { outline:2px solid #E91B89; outline-offset:2px; }
.eti-ind {
position:absolute; top:.4rem; left:0; height:calc(100% - .8rem);
border-radius:999px; background:#E91B89;
transform-origin:center;
transition: transform .42s cubic-bezier(.34,1.4,.42,1), width .42s cubic-bezier(.34,1.4,.42,1);
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.eti-ind { transition-duration:.01ms; }
}
(function(){
var tabs = document.getElementById('eti-tabs');
if (!tabs) return;
var ind = tabs.querySelector('.eti-ind');
var items = Array.prototype.slice.call(tabs.querySelectorAll('.eti-tab'));
var stretchTimer = null;
function place(el, stretch){
var pr = tabs.getBoundingClientRect();
var r = el.getBoundingClientRect();
ind.style.width = r.width + 'px';
ind.style.transform = 'translateX(' + (r.left - pr.left) + 'px) scaleX(' + stretch + ')';
}
function select(el){
items.forEach(function(t){
var on = (t === el);
t.classList.toggle('is-active', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
});
place(el, 1.35);
clearTimeout(stretchTimer);
stretchTimer = setTimeout(function(){ place(el, 1); }, 190);
}
items.forEach(function(t){ t.addEventListener('click', function(){ select(t); }); });
window.addEventListener('resize', function(){
var active = tabs.querySelector('.eti-tab.is-active');
if (active) place(active, 1);
});
var first = tabs.querySelector('.eti-tab.is-active') || items[0];
if (first) { ind.style.transition = 'none'; place(first, 1); void ind.offsetWidth; ind.style.transition = ''; }
})();
AI PROMPT
タブを切り替えるとインジケーターが一度伸びてから縮んで着地する、弾性のあるタブUIを作って。インジケーターはタブ群の中に絶対配置し、選択されたタブの getBoundingClientRect() から幅と左端オフセットを取り、width と transform: translateX() で移動させる(left を直接動かすとレイアウトが再計算されて重い)。弾性の表現は2段階で作る。1) クリック直後に scaleX(1.35) を掛けた状態で移動させる。2) 190ms 後に scaleX(1) へ戻す。この「伸びてから戻る」だけで質量のある動きに見える。イージングは cubic-bezier(.34, 1.4, .42, 1) のようにオーバーシュートするものを選ぶ。初期表示ではトランジションを一時的に none にしてから配置し、強制リフローを挟んで戻す(そうしないと初期位置から飛んでくる)。リサイズ時にも再配置すること。role="tablist" / role="tab" / aria-selected を付け、:focus-visible のアウトラインは消さないこと。 Acceptance criteria: works with prefers-reduced-motion (animation disabled), zero console errors, zero layout shift
AIエージェントでの実装ガイド → Claude Code でWebアニメーションを実装する
BOOKS · PR · AMAZON
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
学びを止めない · AMAZONサブスク
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
タブ切り替え時にインジケーターが一度伸びてから縮んで着地する、質量を感じさせるアニメーションです。移動中に scaleX を一時的に大きくするだけで実現でき、単純な平行移動より「引っ張られて追いついた」印象になります。
当サイトの Segmented Control は選択位置が滑らかに移動する標準的な実装、Underline Slide Hover はホバー時に下線が伸びる表現です。本ページは移動中の変形そのものが主役という別テーマで、iOS のセグメンテッドコントロールや一部のデザインシステムで採用されている挙動に近づきます。
初期配置でトランジションを一時無効化し、強制リフローを挟んでから戻す手順を省くと、ページ読み込み時にインジケーターが画面外から飛んできます。この一手間が仕上がりを分けます。
なぜ left ではなく transform で動かすのですか?
left や margin の変更はレイアウトの再計算(リフロー)を引き起こし、毎フレーム発生すると処理が重くなります。transform: translateX() は合成処理だけで完結するためGPUで滑らかに動きます。幅の変化も本来は避けたいところですが、タブ幅が可変な場合は width のトランジションが必要になるため、scaleX の伸縮と併用して見た目の弾性を作るのが現実的な折衷案です。
初期表示でインジケーターが飛んできます。
初期配置のときにトランジションが効いているためです。配置前に transition を none にし、配置後に強制リフロー(要素の offsetWidth を読む)を挟んでから transition を戻すと、初期位置から動かずに済みます。この「リフローを挟む」手順を省くと、ブラウザがスタイル変更をまとめて処理してしまい効果がありません。
コードは無料で使えますか?
はい。Motion LabのコードスニペットとAIプロンプトは、クレジット表記なしで自由に使えます。コピーしてそのままプロジェクトに利用できます。