インク・タブ
タブを切り替えると下線が液体のように伸び縮みしながらスライド移動し、クリックした地点からインクの波紋が広がるタブUI。ヘッダーの表示切替に使えます。
ライブデモ
コード
HTML
<!-- インク・タブ: アクティブ下線が液体的にスライド移動し、クリック地点からインクの波紋が広がるタブ -->
<div class="stage">
<div class="ink-tabs-wrap">
<div class="ink-tabs" role="tablist" aria-label="表示切替">
<button class="ink-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="ink-panel-0" id="ink-tab-0">デザイン</button>
<button class="ink-tab" type="button" role="tab" aria-selected="false" aria-controls="ink-panel-1" id="ink-tab-1" tabindex="-1">コード</button>
<button class="ink-tab" type="button" role="tab" aria-selected="false" aria-controls="ink-panel-2" id="ink-tab-2" tabindex="-1">共有</button>
<span class="ink-tabs__indicator" aria-hidden="true"></span>
</div>
<div class="ink-panels">
<p class="ink-panel is-active" role="tabpanel" id="ink-panel-0" aria-labelledby="ink-tab-0">色と余白を整えて見た目を作る工程。</p>
<p class="ink-panel" role="tabpanel" id="ink-panel-1" aria-labelledby="ink-tab-1" aria-hidden="true">HTML/CSS/JSへ組み上げる工程。</p>
<p class="ink-panel" role="tabpanel" id="ink-panel-2" aria-labelledby="ink-tab-2" aria-hidden="true">できたものをチームに届ける工程。</p>
</div>
</div>
<p class="hint">タブをクリックすると下線が伸び縮みしながら移動します</p>
</div>
CSS
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, sans-serif;
background: radial-gradient(circle at 50% 30%, #141c33 0%, #0d0e1a 72%);
color: #f4f5fb;
}
.stage { display: grid; place-items: center; gap: 16px; padding: 20px; }
.ink-tabs-wrap { width: min(88vw, 340px); }
.ink-tabs {
position: relative;
display: flex;
gap: 2px;
border-bottom: 1px solid rgba(255, 255, 255, .1);
}
.ink-tab {
position: relative;
overflow: hidden;
flex: 1 1 0;
border: none;
background: transparent;
cursor: pointer;
padding: 11px 10px;
border-radius: 10px 10px 0 0;
font: 600 13.5px/1.2 "Segoe UI", system-ui, sans-serif;
color: rgba(244, 245, 251, .5);
transition: color .25s ease, background-color .2s ease;
-webkit-tap-highlight-color: transparent;
}
.ink-tab:hover { background: rgba(255, 255, 255, .04); }
.ink-tab.is-active { color: #f4f5fb; }
.ink-tab:focus-visible { outline: 2px solid #67e8f9; outline-offset: -3px; }
/* 液体的にスライドする下線。JSが「橋渡し(伸び)→着地(縮み)」の2段階でleft/widthを動かす */
.ink-tabs__indicator {
position: absolute;
left: 0;
bottom: -1px;
height: 3px;
width: 0;
border-radius: 3px;
background: linear-gradient(90deg, #60a5fa, #818cf8);
animation: ink-glow 2.8s ease-in-out infinite;
}
@keyframes ink-glow {
0%, 100% { box-shadow: 0 0 6px rgba(96, 165, 250, .4); }
50% { box-shadow: 0 0 12px rgba(129, 140, 248, .75); }
}
/* クリック地点から広がる波紋(JSが都度生成し、アニメ終了で自身を除去する) */
.ripple {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, .32);
pointer-events: none;
transform: scale(0);
animation: ink-ripple .6s ease-out forwards;
}
@keyframes ink-ripple {
to { transform: scale(1); opacity: 0; }
}
.ink-panels { position: relative; min-height: 2.8em; margin-top: 12px; }
.ink-panel {
position: absolute;
inset: 0;
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(244, 245, 251, .68);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity .3s ease, transform .3s ease;
}
.ink-panel.is-active {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.hint { margin: 0; font-size: 13px; color: rgba(244, 245, 251, .5); text-align: center; }
@media (prefers-reduced-motion: reduce) {
.ink-tabs__indicator { animation: none; }
.ripple { animation-duration: .01ms; }
.ink-panel { transition-duration: .05s; }
}
JavaScript
// インク・タブ: クリックで下線が「橋渡し→着地」の2段階で液体的にスライドし、クリック地点から波紋が広がる
(() => {
const list = document.querySelector('.ink-tabs');
const indicator = document.querySelector('.ink-tabs__indicator');
const tabs = list ? Array.from(list.querySelectorAll('.ink-tab')) : [];
const panels = Array.from(document.querySelectorAll('.ink-panel'));
if (!list || !indicator || !tabs.length) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// インジケータの遷移はJSのインラインstyleで制御するため、reduced-motionはここで直接切り替える
const STRETCH_DUR = reduce ? '.01s' : '.16s';
const SETTLE_DUR = reduce ? '.01s' : '.3s';
let current = tabs.findIndex((t) => t.classList.contains('is-active'));
if (current < 0) current = 0;
const rectOf = (tab) => ({ left: tab.offsetLeft, width: tab.offsetWidth });
const moveIndicator = (fromTab, toTab) => {
const from = rectOf(fromTab);
const to = rectOf(toTab);
const bridgeLeft = Math.min(from.left, to.left);
const bridgeRight = Math.max(from.left + from.width, to.left + to.width);
indicator.style.transition = 'none';
indicator.style.left = `${from.left}px`;
indicator.style.width = `${from.width}px`;
void indicator.offsetWidth; // reflow
// 第1段階: 移動元と移動先を橋渡しするように一旦伸びる(液体が引っ張られる感じ)
indicator.style.transition = `left ${STRETCH_DUR} cubic-bezier(.34, 1.56, .64, 1), width ${STRETCH_DUR} cubic-bezier(.34, 1.56, .64, 1)`;
indicator.style.left = `${bridgeLeft}px`;
indicator.style.width = `${bridgeRight - bridgeLeft}px`;
const onStretch = (e) => {
if (e.target !== indicator || e.propertyName !== 'width') return;
indicator.removeEventListener('transitionend', onStretch);
// 第2段階: 移動先の幅に着地する(液体が縮んで馴染む)
indicator.style.transition = `left ${SETTLE_DUR} cubic-bezier(.34, 1.56, .64, 1), width ${SETTLE_DUR} cubic-bezier(.34, 1.56, .64, 1)`;
indicator.style.left = `${to.left}px`;
indicator.style.width = `${to.width}px`;
};
indicator.addEventListener('transitionend', onStretch);
};
const activate = (index) => {
if (index === current) return;
moveIndicator(tabs[current], tabs[index]);
tabs.forEach((t, i) => {
const on = i === index;
t.classList.toggle('is-active', on);
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
});
panels.forEach((p, i) => {
const on = i === index;
p.classList.toggle('is-active', on);
p.setAttribute('aria-hidden', String(!on));
});
current = index;
};
const spawnRipple = (tab, clientX, clientY) => {
const rect = tab.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 1.8;
const span = document.createElement('span');
span.className = 'ripple';
span.style.width = `${size}px`;
span.style.height = `${size}px`;
span.style.left = `${clientX - rect.left - size / 2}px`;
span.style.top = `${clientY - rect.top - size / 2}px`;
span.addEventListener('animationend', () => span.remove());
tab.appendChild(span);
};
tabs.forEach((tab, i) => {
tab.addEventListener('click', (e) => {
const rect = tab.getBoundingClientRect();
const cx = typeof e.clientX === 'number' && e.clientX !== 0 ? e.clientX : rect.left + rect.width / 2;
const cy = typeof e.clientY === 'number' && e.clientY !== 0 ? e.clientY : rect.top + rect.height / 2;
spawnRipple(tab, cx, cy);
activate(i);
});
tab.addEventListener('keydown', (e) => {
let next = null;
if (e.key === 'ArrowRight') next = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
if (next !== null) {
e.preventDefault();
tabs[next].focus();
activate(next);
}
});
});
// 初期位置をレイアウト確定後に合わせる(フォント読み込みによるズレを避ける)
const placeInitial = () => {
const t = tabs[current];
indicator.style.transition = 'none';
indicator.style.left = `${t.offsetLeft}px`;
indicator.style.width = `${t.offsetWidth}px`;
};
placeInitial();
window.requestAnimationFrame(placeInitial);
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(placeInitial).catch(() => {});
}
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「インク・タブ」の効果を追加してください。
# 追加してほしい効果
インク・タブ(遊べるUI)
タブを切り替えると下線が液体のように伸び縮みしながらスライド移動し、クリックした地点からインクの波紋が広がるタブUI。ヘッダーの表示切替に使えます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- インク・タブ: アクティブ下線が液体的にスライド移動し、クリック地点からインクの波紋が広がるタブ -->
<div class="stage">
<div class="ink-tabs-wrap">
<div class="ink-tabs" role="tablist" aria-label="表示切替">
<button class="ink-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="ink-panel-0" id="ink-tab-0">デザイン</button>
<button class="ink-tab" type="button" role="tab" aria-selected="false" aria-controls="ink-panel-1" id="ink-tab-1" tabindex="-1">コード</button>
<button class="ink-tab" type="button" role="tab" aria-selected="false" aria-controls="ink-panel-2" id="ink-tab-2" tabindex="-1">共有</button>
<span class="ink-tabs__indicator" aria-hidden="true"></span>
</div>
<div class="ink-panels">
<p class="ink-panel is-active" role="tabpanel" id="ink-panel-0" aria-labelledby="ink-tab-0">色と余白を整えて見た目を作る工程。</p>
<p class="ink-panel" role="tabpanel" id="ink-panel-1" aria-labelledby="ink-tab-1" aria-hidden="true">HTML/CSS/JSへ組み上げる工程。</p>
<p class="ink-panel" role="tabpanel" id="ink-panel-2" aria-labelledby="ink-tab-2" aria-hidden="true">できたものをチームに届ける工程。</p>
</div>
</div>
<p class="hint">タブをクリックすると下線が伸び縮みしながら移動します</p>
</div>
【CSS】
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, sans-serif;
background: radial-gradient(circle at 50% 30%, #141c33 0%, #0d0e1a 72%);
color: #f4f5fb;
}
.stage { display: grid; place-items: center; gap: 16px; padding: 20px; }
.ink-tabs-wrap { width: min(88vw, 340px); }
.ink-tabs {
position: relative;
display: flex;
gap: 2px;
border-bottom: 1px solid rgba(255, 255, 255, .1);
}
.ink-tab {
position: relative;
overflow: hidden;
flex: 1 1 0;
border: none;
background: transparent;
cursor: pointer;
padding: 11px 10px;
border-radius: 10px 10px 0 0;
font: 600 13.5px/1.2 "Segoe UI", system-ui, sans-serif;
color: rgba(244, 245, 251, .5);
transition: color .25s ease, background-color .2s ease;
-webkit-tap-highlight-color: transparent;
}
.ink-tab:hover { background: rgba(255, 255, 255, .04); }
.ink-tab.is-active { color: #f4f5fb; }
.ink-tab:focus-visible { outline: 2px solid #67e8f9; outline-offset: -3px; }
/* 液体的にスライドする下線。JSが「橋渡し(伸び)→着地(縮み)」の2段階でleft/widthを動かす */
.ink-tabs__indicator {
position: absolute;
left: 0;
bottom: -1px;
height: 3px;
width: 0;
border-radius: 3px;
background: linear-gradient(90deg, #60a5fa, #818cf8);
animation: ink-glow 2.8s ease-in-out infinite;
}
@keyframes ink-glow {
0%, 100% { box-shadow: 0 0 6px rgba(96, 165, 250, .4); }
50% { box-shadow: 0 0 12px rgba(129, 140, 248, .75); }
}
/* クリック地点から広がる波紋(JSが都度生成し、アニメ終了で自身を除去する) */
.ripple {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, .32);
pointer-events: none;
transform: scale(0);
animation: ink-ripple .6s ease-out forwards;
}
@keyframes ink-ripple {
to { transform: scale(1); opacity: 0; }
}
.ink-panels { position: relative; min-height: 2.8em; margin-top: 12px; }
.ink-panel {
position: absolute;
inset: 0;
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(244, 245, 251, .68);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity .3s ease, transform .3s ease;
}
.ink-panel.is-active {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.hint { margin: 0; font-size: 13px; color: rgba(244, 245, 251, .5); text-align: center; }
@media (prefers-reduced-motion: reduce) {
.ink-tabs__indicator { animation: none; }
.ripple { animation-duration: .01ms; }
.ink-panel { transition-duration: .05s; }
}
【JavaScript】
// インク・タブ: クリックで下線が「橋渡し→着地」の2段階で液体的にスライドし、クリック地点から波紋が広がる
(() => {
const list = document.querySelector('.ink-tabs');
const indicator = document.querySelector('.ink-tabs__indicator');
const tabs = list ? Array.from(list.querySelectorAll('.ink-tab')) : [];
const panels = Array.from(document.querySelectorAll('.ink-panel'));
if (!list || !indicator || !tabs.length) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// インジケータの遷移はJSのインラインstyleで制御するため、reduced-motionはここで直接切り替える
const STRETCH_DUR = reduce ? '.01s' : '.16s';
const SETTLE_DUR = reduce ? '.01s' : '.3s';
let current = tabs.findIndex((t) => t.classList.contains('is-active'));
if (current < 0) current = 0;
const rectOf = (tab) => ({ left: tab.offsetLeft, width: tab.offsetWidth });
const moveIndicator = (fromTab, toTab) => {
const from = rectOf(fromTab);
const to = rectOf(toTab);
const bridgeLeft = Math.min(from.left, to.left);
const bridgeRight = Math.max(from.left + from.width, to.left + to.width);
indicator.style.transition = 'none';
indicator.style.left = `${from.left}px`;
indicator.style.width = `${from.width}px`;
void indicator.offsetWidth; // reflow
// 第1段階: 移動元と移動先を橋渡しするように一旦伸びる(液体が引っ張られる感じ)
indicator.style.transition = `left ${STRETCH_DUR} cubic-bezier(.34, 1.56, .64, 1), width ${STRETCH_DUR} cubic-bezier(.34, 1.56, .64, 1)`;
indicator.style.left = `${bridgeLeft}px`;
indicator.style.width = `${bridgeRight - bridgeLeft}px`;
const onStretch = (e) => {
if (e.target !== indicator || e.propertyName !== 'width') return;
indicator.removeEventListener('transitionend', onStretch);
// 第2段階: 移動先の幅に着地する(液体が縮んで馴染む)
indicator.style.transition = `left ${SETTLE_DUR} cubic-bezier(.34, 1.56, .64, 1), width ${SETTLE_DUR} cubic-bezier(.34, 1.56, .64, 1)`;
indicator.style.left = `${to.left}px`;
indicator.style.width = `${to.width}px`;
};
indicator.addEventListener('transitionend', onStretch);
};
const activate = (index) => {
if (index === current) return;
moveIndicator(tabs[current], tabs[index]);
tabs.forEach((t, i) => {
const on = i === index;
t.classList.toggle('is-active', on);
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
});
panels.forEach((p, i) => {
const on = i === index;
p.classList.toggle('is-active', on);
p.setAttribute('aria-hidden', String(!on));
});
current = index;
};
const spawnRipple = (tab, clientX, clientY) => {
const rect = tab.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 1.8;
const span = document.createElement('span');
span.className = 'ripple';
span.style.width = `${size}px`;
span.style.height = `${size}px`;
span.style.left = `${clientX - rect.left - size / 2}px`;
span.style.top = `${clientY - rect.top - size / 2}px`;
span.addEventListener('animationend', () => span.remove());
tab.appendChild(span);
};
tabs.forEach((tab, i) => {
tab.addEventListener('click', (e) => {
const rect = tab.getBoundingClientRect();
const cx = typeof e.clientX === 'number' && e.clientX !== 0 ? e.clientX : rect.left + rect.width / 2;
const cy = typeof e.clientY === 'number' && e.clientY !== 0 ? e.clientY : rect.top + rect.height / 2;
spawnRipple(tab, cx, cy);
activate(i);
});
tab.addEventListener('keydown', (e) => {
let next = null;
if (e.key === 'ArrowRight') next = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
if (next !== null) {
e.preventDefault();
tabs[next].focus();
activate(next);
}
});
});
// 初期位置をレイアウト確定後に合わせる(フォント読み込みによるズレを避ける)
const placeInitial = () => {
const t = tabs[current];
indicator.style.transition = 'none';
indicator.style.left = `${t.offsetLeft}px`;
indicator.style.width = `${t.offsetWidth}px`;
};
placeInitial();
window.requestAnimationFrame(placeInitial);
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(placeInitial).catch(() => {});
}
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。