HTML / CSS / JS
アニメーション
2026/05/21
2026/5/12
テキストをただ表示するだけでなく、金属のような質感で立体的に浮かび上がらせたい――そんな演出を、Three.jsひとつで実現するのがこのデモです。
入力欄にテキストを打ち込んで「Morph」ボタンを押すと、1文字ずつスケールアニメーションしながら3Dメタル文字が出現します。
メタリックな質感はCanvasで生成したブラシテクスチャとMeshStandardMaterialの金属パラメータで表現しており、複数のライトによる陰影がリアルさを引き立てます。
OrbitControlsで自由に回転・ズームして、あらゆる角度から文字を眺めることができます。
<div class="kumonosu-ui-container">
<input type="text" id="kumonosu-morphInput" class="kumonosu-text-input" placeholder="Type here..." maxlength="20" value="HAPPY">
<button id="kumonosu-morphBtn" class="kumonosu-morph-button">Morph</button>
<div class="kumonosu-disclaimer">※日本語には対応していません</div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
body {
margin: 0;
overflow: hidden;
background-color: #050505;
font-family: 'Inter', sans-serif;
}
.kumonosu-ui-container {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 100;
padding: 10px;
border-radius: 14px;
backdrop-filter: blur(10px);
width: auto;
max-width: 90vw;
box-sizing: border-box;
}
.kumonosu-text-input {
width: 300px;
background: #181818;
border: 1px solid #333;
border-radius: 8px;
color: #fff;
padding: 12px 16px;
font-size: 16px;
outline: none;
transition: border-color 0.2s;
flex-shrink: 1;
min-width: 0;
}
.kumonosu-text-input:focus {
border-color: #555;
}
.kumonosu-text-input::placeholder {
color: #555;
}
.kumonosu-disclaimer {
position: absolute;
bottom: -22px;
left: 14px;
font-size: 10px;
color: #666;
letter-spacing: 0.02em;
pointer-events: none;
white-space: nowrap;
}
.kumonosu-morph-button {
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
padding: 0 20px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: transform 0.1s, background 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kumonosu-morph-button:hover {
background: #4f46e5;
}
.kumonosu-morph-button:active {
transform: scale(0.95);
}
@media (max-width: 600px) {
.kumonosu-ui-container {
bottom: 30px;
width: 92vw;
padding: 8px;
}
.kumonosu-text-input {
width: 100%;
padding: 10px 12px;
font-size: 14px;
}
.kumonosu-morph-button {
padding: 0 15px;
font-size: 14px;
}
.kumonosu-disclaimer {
bottom: -18px;
font-size: 9px;
}
}
import * as THREE from 'https://esm.sh/three@0.150.0';
import {
OrbitControls
} from 'https://esm.sh/three@0.150.0/examples/jsm/controls/OrbitControls.js';
import {
TextGeometry
} from 'https://esm.sh/three@0.150.0/examples/jsm/geometries/TextGeometry.js';
import {
FontLoader
} from 'https://esm.sh/three@0.150.0/examples/jsm/loaders/FontLoader.js';
// --- シーン設定 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
function adjustCamera() {
camera.aspect = window.innerWidth / window.innerHeight;
if (window.innerWidth < 600) {
camera.position.set(0, 0.8, 7.5);
} else {
camera.position.set(-1.8, 0.8, 4.5);
}
camera.updateProjectionMatrix();
}
adjustCamera();
const renderer = new THREE.WebGLRenderer({
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// --- ライティング ---
const ambientLight = new THREE.AmbientLight(0x404050, 0.6);
const mainLight = new THREE.DirectionalLight(0xffffff, 1.5);
mainLight.position.set(5, 5, 5);
mainLight.castShadow = true;
const backLight = new THREE.PointLight(0xffaa88, 1.2);
backLight.position.set(-3, 2, -4);
const rimLight = new THREE.PointLight(0xffffff, 0.8);
rimLight.position.set(0, 4, -2);
const frontLight = new THREE.PointLight(0xaaccff, 0.5);
frontLight.position.set(0, 0, 4);
const bottomLight = new THREE.PointLight(0x443322, 0.4);
bottomLight.position.set(0, -3, 0);
[ambientLight, mainLight, backLight, rimLight, frontLight, bottomLight].forEach(l => scene.add(l));
const FONT_URL = 'https://threejs.org/examples/fonts/helvetiker_bold.typeface.json';
// --- メタルテクスチャ ---
function createMetalMap() {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#c0c0c0';
ctx.fillRect(0, 0, 512, 512);
for (let i = 0; i < 3000; i++) {
ctx.fillStyle = `rgba(255,255,255,${Math.random() * 0.04})`;
ctx.fillRect(Math.random() * 512, Math.random() * 512, 1, 20);
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
return tex;
}
let currentFont = null;
const metalMap = createMetalMap();
let textGroup = new THREE.Group();
scene.add(textGroup);
async function rebuildText(newText) {
if (!currentFont || !newText) return;
const oldMeshes = [...textGroup.children];
oldMeshes.forEach((m) => {
let s = 1;
const fade = () => {
s -= 0.15;
m.scale.set(s, s, s);
if (s > 0) requestAnimationFrame(fade);
else {
if (m.geometry) m.geometry.dispose();
if (m.material) m.material.dispose();
textGroup.remove(m);
}
};
fade();
});
const chars = newText.toUpperCase().split('');
const meshes = [];
let totalX = 0;
const spacing = 0.02; // 文字間隔
chars.forEach((char) => {
if (char === ' ') {
totalX += 0.3;
return;
}
const geo = new TextGeometry(char, {
font: currentFont,
size: 0.45,
height: 0.2,
curveSegments: 12,
bevelEnabled: true,
bevelThickness: 0.04,
bevelSize: 0.03,
bevelSegments: 5
});
geo.computeBoundingBox();
const mat = new THREE.MeshStandardMaterial({
color: 0xc0c0c0,
map: metalMap,
metalness: 0.9,
roughness: 0.2
});
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
mesh.userData.offsetX = geo.boundingBox.min.x;
mesh.userData.width = geo.boundingBox.max.x - geo.boundingBox.min.x;
mesh.userData.posX = totalX;
totalX += mesh.userData.width + spacing;
meshes.push(mesh);
});
const centerOffset = (totalX - spacing) / 2;
meshes.forEach((m, i) => {
m.position.x = m.userData.posX - centerOffset - m.userData.offsetX;
m.scale.set(0, 0, 0);
textGroup.add(m);
setTimeout(() => {
let s = 0;
const anim = () => {
s += 0.12;
m.scale.set(s, s, s);
if (s < 1) requestAnimationFrame(anim);
else m.scale.set(1, 1, 1);
};
anim();
}, i * 40);
});
}
const input = document.getElementById('kumonosu-morphInput');
const btn = document.getElementById('kumonosu-morphBtn');
const handleMorph = () => {
const val = input.value.trim();
if (val) rebuildText(val);
};
btn.addEventListener('click', handleMorph);
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleMorph();
});
new FontLoader().load(FONT_URL, f => {
currentFont = f;
rebuildText("HAPPY");
});
window.addEventListener('resize', () => {
adjustCamera();
renderer.setSize(window.innerWidth, window.innerHeight);
});
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
このデモは、Three.jsのTextGeometryを使って英字テキストを1文字ずつ3Dメッシュ化し、金属質感のマテリアルを適用してリアルタイム描画します。
主な機能は以下のとおりです。
MeshStandardMaterialに適用し、metalness: 0.9・roughness: 0.2で金属的な光沢を再現しています。rebuildText("HAPPY")の引数を書き換えると、初期表示される文字を変更できます。color(デフォルト0xc0c0c0=シルバー)を変更すれば、ゴールドや銅色など自由に変えられます。たとえばゴールドなら0xd4a84bのような値を指定します。metalness(金属度)とroughness(粗さ)の値を変えると、ピカピカの鏡面からマットな金属まで質感を調整できます。size(文字の大きさ)とheight(押し出しの厚み)を変更すると、文字のボリューム感が変わります。bevelThickness・bevelSize・bevelSegmentsで文字の角の丸みを調整できます。値を大きくするとより丸みを帯びた仕上がりになります。scene.backgroundの色を変更すれば、背景の雰囲気を変えられます。.kumonosu-morph-buttonのbackgroundを変更すれば、ボタンカラーをサイトのテーマに合わせられます。unpkg.com経由でThree.js v0.150.0とOrbitControls・TextGeometry・FontLoaderを読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。threejs.orgからJSON形式のフォントを非同期で読み込んでいます。ネットワーク状況によっては初期表示に時間がかかる場合があります。dispose()で明示的に解放していますが、頻繁に切り替えるとメモリ負荷が蓄積する可能性があります。devicePixelRatioの上限を2に制限しています。文字数が多い場合やベベルのセグメント数を増やしすぎると、頂点数が増加してパフォーマンスに影響します。type="importmap"を使用しているため、対応していない古いブラウザでは動作しません。