ToDo管理や優先順位の調整など、リストの並び替えは日常的によく使われる操作です。しかし、ただ項目が入れ替わるだけでは味気なく、操作している感覚も薄れてしまいます。
このパーツでは、ドラッグ&ドロップによる並び替えに「スルッ!」「ドン!」などのオノマトペを添えることで、操作に楽しさとフィードバックを加えます。視覚的なエフェクトだけでなく、言葉による表現が加わることで、UIに生き生きとした印象を与えられます。
作成したオノマトペ付きソータブルリスト
ドラッグ&ドロップで並び替えでき、入れ替え時とドロップ時だけスルッ!ドン!などオノマトペが出るリストパーツ(Vanilla JS)
HTML
<div class="list-wrapper">
<ul id="sortable">
<li class="sortable-item" data-key="A01"><span class="item-id">#01</span><span class="item-text">メールを確認する</span></li>
<li class="sortable-item" data-key="A02"><span class="item-id">#02</span><span class="item-text">議事録をまとめる</span></li>
<li class="sortable-item" data-key="A03"><span class="item-id">#03</span><span class="item-text">資料を更新する</span></li>
<li class="sortable-item" data-key="A04"><span class="item-id">#04</span><span class="item-text">コードレビューをする</span></li>
<li class="sortable-item" data-key="A05"><span class="item-id">#05</span><span class="item-text">バグを修正する</span></li>
<li class="sortable-item" data-key="A06"><span class="item-id">#06</span><span class="item-text">公開前チェックをする</span></li>
</ul>
</div> CSS
:root {
--bg-color: #f4f4f5;
--text-color: #333;
--border-color: #e4e4e7;
--item-bg: #fff;
--accent-color: #6366f1;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background-color: var(--bg-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow-x: hidden;
padding: 32px 16px;
}
body.is-dragging {
user-select: none;
cursor: grabbing;
}
/* コンテナ */
.list-wrapper {
width: 100%;
max-width: 400px;
background: var(--item-bg);
border-radius: 16px;
box-shadow: 0 10px 40px -10px rgba(0,0,0,0.1);
overflow: hidden;
margin: 0 auto;
}
/* ソータブルリスト */
#sortable {
list-style: none;
padding: 12px;
margin: 0;
touch-action: none;
}
.sortable-item {
background: #f4f4f5;
padding: 16px 20px;
margin-bottom: 8px;
border-radius: 12px;
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 16px;
font-weight: 500;
cursor: grab;
display: flex;
align-items: center;
gap: 12px;
transition:
transform 0.2s cubic-bezier(0.25, 1, 0.5, 1),
box-shadow 0.2s cubic-bezier(0.25, 1, 0.5, 1),
border-color 0.2s cubic-bezier(0.25, 1, 0.5, 1);
position: relative;
}
.item-id {
flex: 0 0 auto;
min-width: 40px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid #d4d4d8;
background: #fff;
color: #52525b;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-align: center;
}
.item-text {
flex: 1;
}
#sortable .sortable-item:last-child {
margin-bottom: 0;
}
#sortable .sortable-item:hover {
border-color: #d4d4d8;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
#sortable .sortable-item:active {
cursor: grabbing;
}
#sortable li.drag-source {
display: none;
}
#sortable li.sortable-placeholder {
background: #f4f4f5;
border: 1px dashed #d4d4d8;
border-radius: 12px;
margin-bottom: 8px;
position: relative;
overflow: hidden;
}
/* 空白に見えないよう、プレースホルダーに薄い“行の残像”を出す */
#sortable li.sortable-placeholder::before,
#sortable li.sortable-placeholder::after {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 14px;
border-radius: 999px;
background: rgba(212, 212, 216, 0.75);
}
#sortable li.sortable-placeholder::before {
left: 20px;
width: 44px;
height: 24px;
background: rgba(212, 212, 216, 0.55);
}
#sortable li.sortable-placeholder::after {
left: 84px;
right: 20px;
}
/* ドラッグ中の本体(jQuery UIのhelper相当) */
.dragging-item {
position: fixed;
pointer-events: none;
z-index: 9999;
margin: 0 !important;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.12), 0 10px 10px -5px rgba(0, 0, 0, 0.06);
border-color: var(--accent-color);
opacity: 0.96;
transform: none !important;
transition: none !important;
cursor: grabbing;
}
/* オノマトペ表示エリア */
.onomatope {
position: absolute;
font-size: 24px;
font-weight: 900;
pointer-events: none;
white-space: nowrap;
z-index: 9999;
color: #111;
/* 漫画的な表現 */
text-shadow:
2px 2px 0 #fff,
-2px -2px 0 #fff,
2px -2px 0 #fff,
-2px 2px 0 #fff,
3px 3px 0 rgba(0,0,0,0.1);
animation: popIn 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
/* バリエーション */
.style-1 { font-family: 'Kiwi Maru', serif; }
.style-2 { font-family: 'Yusei Magic', sans-serif; }
.style-3 { font-family: 'Potta One', cursive; }
.style-4 { font-family: 'Dela Gothic One', cursive; }
.style-5 { font-family: 'Rampart One', cursive; }
.style-6 { font-family: 'RocknRoll One', sans-serif; }
.style-7 { font-family: 'DotGothic16', sans-serif; }
@keyframes popIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.5) rotate(var(--rotate));
}
20% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.2) rotate(var(--rotate));
}
40% {
transform: translate(-50%, -50%) scale(1) rotate(var(--rotate));
}
100% {
opacity: 0;
transform: translate(-50%, -60%) scale(0.9) rotate(var(--rotate));
}
} JavaScript
// オノマトペを表示
function displayOnomatope(text, x, y, scale = 1) {
const div = document.createElement('div');
div.className = 'onomatope';
div.textContent = text;
// ランダムなフォントスタイル
const styleNum = Math.floor(Math.random() * 7) + 1;
div.classList.add(`style-${styleNum}`);
// 適切なサイズ
const baseSize = 32; // 基本サイズを小さく調整
div.style.fontSize = `${baseSize * scale}px`;
// ランダムな回転角度 (-20deg ~ 20deg)
const rotation = (Math.random() - 0.5) * 40;
div.style.setProperty('--rotate', `${rotation}deg`);
// 位置設定
div.style.left = `${x}px`;
div.style.top = `${y}px`;
document.body.appendChild(div);
// アニメーション終了後に削除
setTimeout(() => {
div.remove();
}, 1000);
}
(function initCustomSortable() {
const list = document.getElementById('sortable');
if (!list) return;
const changeTexts = ['クルン', 'シュッ', 'ピコッ', 'ゴゴゴ', 'ヒョイ', 'カサッ'];
const stopTexts = ['ピタッ', 'ストン', 'ドン', 'パチッ'];
let dragItem = null;
let placeholder = null;
let pointerId = null;
let offsetY = 0;
let offsetX = 0;
let dragHeight = 0;
let lastClientY = 0;
let lastPlaceholderIndex = -1;
function randomPick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function getCenterOfElement(el) {
const r = el.getBoundingClientRect();
return {
x: r.left + r.width / 2 + window.scrollX,
y: r.top + r.height / 2 + window.scrollY,
rect: r
};
}
function movePlaceholderByPointer(probeY) {
const items = Array.from(list.children).filter((el) => el !== placeholder);
const target = items.find((el) => {
const r = el.getBoundingClientRect();
return probeY < r.top + r.height / 2;
});
if (target) {
if (placeholder.nextSibling !== target) {
list.insertBefore(placeholder, target);
}
} else {
list.appendChild(placeholder);
}
const newIndex = Array.from(list.children).indexOf(placeholder);
if (newIndex !== lastPlaceholderIndex) {
lastPlaceholderIndex = newIndex;
const c = getCenterOfElement(placeholder);
displayOnomatope(randomPick(changeTexts), c.x, c.y);
}
}
function cleanupDrag() {
document.body.classList.remove('is-dragging');
if (pointerId !== null) {
try {
list.releasePointerCapture(pointerId);
} catch {
// ignore
}
}
if (dragItem) {
dragItem.classList.remove('dragging-item');
dragItem.style.left = '';
dragItem.style.top = '';
dragItem.style.width = '';
}
dragItem = null;
placeholder = null;
pointerId = null;
lastPlaceholderIndex = -1;
}
list.addEventListener('pointerdown', (e) => {
const li = e.target instanceof Element ? e.target.closest('li') : null;
if (!li || !list.contains(li)) return;
e.preventDefault();
pointerId = e.pointerId;
// 枠外に出ても追従し続けるよう、リスト側でポインタをキャプチャ
list.setPointerCapture(pointerId);
dragItem = li;
const rect = li.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
dragHeight = rect.height;
lastClientY = e.clientY;
placeholder = document.createElement('li');
placeholder.className = 'sortable-placeholder';
placeholder.style.height = `${rect.height}px`;
placeholder.style.borderRadius = getComputedStyle(li).borderRadius;
// まずプレースホルダーを同じ位置に入れ、ドラッグ対象の<li>はbodyに退避させて追従させる
list.replaceChild(placeholder, li);
document.body.appendChild(li);
dragItem.classList.add('dragging-item');
dragItem.style.width = `${rect.width}px`;
dragItem.style.left = `${rect.left}px`;
dragItem.style.top = `${rect.top}px`;
document.body.classList.add('is-dragging');
lastPlaceholderIndex = Array.from(list.children).indexOf(placeholder);
});
list.addEventListener('pointermove', (e) => {
if (!dragItem || e.pointerId !== pointerId || !placeholder) return;
// 本体をカーソルに追従(jQuery UIの挙動に近い)
const nextLeft = e.clientX - offsetX;
const nextTop = e.clientY - offsetY;
dragItem.style.left = `${nextLeft}px`;
dragItem.style.top = `${nextTop}px`;
// jQuery UIの体感に寄せる:
// 下方向は「底」が相手の半分を超えたら、上方向は「上辺」が相手の半分を超えたら入れ替える
const movingDown = e.clientY >= lastClientY;
const probeY = movingDown ? (nextTop + dragHeight) : nextTop;
movePlaceholderByPointer(probeY);
lastClientY = e.clientY;
});
function finalize(e) {
if (!dragItem || e.pointerId !== pointerId || !placeholder) return;
// body上の要素を、プレースホルダー位置へ戻す(<li>ごと移動)
list.replaceChild(dragItem, placeholder);
const c = getCenterOfElement(dragItem);
displayOnomatope(randomPick(stopTexts), c.x, c.y, 1.2);
cleanupDrag();
}
list.addEventListener('pointerup', finalize);
list.addEventListener('pointercancel', cleanupDrag);
window.addEventListener('blur', cleanupDrag);
})(); このパーツの特徴
- Vanilla JSで実装 ライブラリ不要で軽量に動作するドラッグ&ドロップ機能
- オノマトペ演出 入れ替え時に「クルン」「シュッ」、ドロップ時に「ドン!」などが表示
- ランダムフォント 7種類の日本語フォントからランダムに選ばれ、毎回違った表情を演出
- スムーズな操作感 プレースホルダーで挿入位置を明示し、直感的に並び替え可能
- 漫画風デザイン text-shadowで白い縁取りを施し、コミカルな印象を強調
コードのポイント
オノマトペの表示ロジック
オノマトペはdisplayOnomatope()関数で要素の中心座標に表示されます。ランダムなフォントスタイルと回転角度で、毎回異なる表情を演出します。
function displayOnomatope(text, x, y, scale = 1) {
const div = document.createElement('div');
div.className = 'onomatope';
div.textContent = text;
// ランダムなフォントスタイル
const styleNum = Math.floor(Math.random() * 7) + 1;
div.classList.add(`style-${styleNum}`);
// ランダムな回転角度 (-20deg ~ 20deg)
const rotation = (Math.random() - 0.5) * 40;
div.style.setProperty('--rotate', `${rotation}deg`);
// 位置設定
div.style.left = `${x}px`;
div.style.top = `${y}px`;
document.body.appendChild(div);
// アニメーション終了後に削除
setTimeout(() => {
div.remove();
}, 1000);
} プレースホルダーの移動検知
ドラッグ中の位置に応じてプレースホルダーを移動させ、位置が変わったときだけオノマトペを表示します。
function movePlaceholderByPointer(probeY) {
const items = Array.from(list.children).filter((el) => el !== placeholder);
const target = items.find((el) => {
const r = el.getBoundingClientRect();
return probeY < r.top + r.height / 2;
});
if (target) {
if (placeholder.nextSibling !== target) {
list.insertBefore(placeholder, target);
}
} else {
list.appendChild(placeholder);
}
const newIndex = Array.from(list.children).indexOf(placeholder);
if (newIndex !== lastPlaceholderIndex) {
lastPlaceholderIndex = newIndex;
const c = getCenterOfElement(placeholder);
displayOnomatope(randomPick(changeTexts), c.x, c.y);
}
} 漫画風のオノマトペスタイル
text-shadowで白い縁取りを複数方向に配置し、漫画のような視覚効果を実現しています。
.onomatope {
position: absolute;
font-size: 24px;
font-weight: 900;
pointer-events: none;
white-space: nowrap;
z-index: 9999;
color: #111;
/* 漫画的な表現 */
text-shadow:
2px 2px 0 #fff,
-2px -2px 0 #fff,
2px -2px 0 #fff,
-2px 2px 0 #fff,
3px 3px 0 rgba(0,0,0,0.1);
animation: popIn 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
} ドラッグ要素のキャプチャ
Pointer APIのsetPointerCapture()を使うことで、カーソルがリスト外に出てもドラッグ操作を継続できます。
list.addEventListener('pointerdown', (e) => {
const li = e.target instanceof Element ? e.target.closest('li') : null;
if (!li || !list.contains(li)) return;
e.preventDefault();
pointerId = e.pointerId;
// 枠外に出ても追従し続けるよう、リスト側でポインタをキャプチャ
list.setPointerCapture(pointerId);
dragItem = li;
// ... (以下、ドラッグ処理)
}); まとめ
オノマトペ付きソータブルリストのポイント
- 言葉による演出 視覚効果だけでなく、オノマトペで操作に楽しさを追加
- Vanilla JS実装 ライブラリ不要で軽量かつカスタマイズしやすい構造
- ランダム要素 フォントと回転角度のランダム化で、飽きのこない表現
- プレースホルダー機能 挿入位置を視覚的に示し、直感的な操作を実現
- Pointer API活用 タッチデバイスでも安定したドラッグ&ドロップ操作
オノマトペという日本語特有の表現を活かしたUIパーツです。遊び心のあるフィードバックが、ユーザーの操作体験をより楽しく、印象的なものにします。