Cara Membuat Kartu Bertumpuk Saat Scroll, Ponsel 3D, Keycap, dan Testimoni Gaya Chat dengan CSS & JS (plus CMS PHP)
Cara Membuat Kartu Bertumpuk Saat Scroll, Ponsel 3D, Keycap, dan Testimoni Gaya Chat dengan CSS & JS (plus CMS PHP)
Portofolio desainer UI/UX punya beban ganda: isinya harus bagus, dan halamannya sendiri harus membuktikan bahwa pemiliknya paham interaksi. Template Memphis memakai gaya 90-an yang ceria (bentuk geometris, warna cerah flat, garis tebal) dan menambahkan interaksi kecil yang menyenangkan. Artikel ini membedah lima teknik intinya, semuanya tanpa library animasi.
1. Tombol timbul tanpa gradasi
Kesan "bisa ditekan" cukup dibuat dari bayangan padat tanpa blur. Saat ditekan, tombol turun sejauh bayangannya, sehingga bayangan seolah menghilang ke bawah tombol:
.btn{border:2.5px solid #231942;border-radius:999px;box-shadow:0 6px 0 #231942;
transition:transform .12s,box-shadow .12s}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 0 #231942}
.btn:active{transform:translateY(5px);box-shadow:0 1px 0 #231942}
Total "tinggi" tombol tetap sama (posisi + bayangan), jadi tata letak di sekitarnya tidak ikut bergeser. Teknik yang sama dipakai untuk kartu, chip filter, dan kotak testimoni.
2. Kartu proyek yang menumpuk saat di-scroll
Setiap kartu diberi position: sticky dengan jarak atas yang sedikit bertambah sesuai urutannya. Kartu berikutnya akan naik dan menutupi kartu sebelumnya, tetapi tepi atas kartu lama tetap mengintip:
.scard{position:sticky;top:calc(110px + var(--i) * 14px)}
<a class="scard" style="--i:<?= $i ?>">...</a>
Supaya terasa bertumpuk sungguhan, kartu yang sedang ditimpa dibuat sedikit mengecil dan miring. Caranya dengan mengukur seberapa dekat kartu berikutnya:
vis.forEach((c, i) => {
const next = vis[i + 1];
if (!next) { c.style.transform = ''; return; }
const gap = next.getBoundingClientRect().top - c.getBoundingClientRect().top;
const p = Math.min(1, Math.max(0, 1 - gap / c.offsetHeight));
c.style.transform = `scale(${1 - p * 0.06}) rotate(${p * (i % 2 ? 1.2 : -1.2)}deg)`;
});
Nilai p bergerak dari 0 (kartu berikutnya masih jauh) sampai 1 (kartu berikutnya sudah menempel). Variabel vis hanya berisi kartu yang tidak disembunyikan filter, sehingga tumpukan tetap benar setelah pengunjung memilih kategori.
3. Ponsel 3D yang menampilkan karya terbaru
Ponsel dibangun dari dua lapis: muka depan (layar) yang didorong ke depan dan punggung berwarna yang didorong ke belakang. Saat diputar, ketebalannya terlihat:
.phone{width:270px;height:550px;transform-style:preserve-3d;transform:rotateY(-22deg) rotateX(8deg)}
.phone-face{position:absolute;inset:0;border-radius:42px;transform:translateZ(14px)}
.phone-side{position:absolute;inset:0;border-radius:42px;background:var(--vio);transform:translateZ(-14px)}
Sudutnya digabung dari posisi kursor dan posisi scroll, jadi ponsel tetap bergerak di HP walaupun tidak ada kursor:
phone.style.transform = `rotateY(${-22 + mx * 30 + sy / 18}deg) rotateX(${8 - my * 16}deg) rotateZ(${sy / 60}deg)`;
Isi layarnya diambil otomatis di PHP: gambar galeri pertama dari proyek unggulan pertama, atau cover-nya jika galeri kosong.
$utama = array_values(array_filter($D['proyek'], fn($p) => $p['unggulan']))[0] ?? ($D['proyek'][0] ?? null);
$layar = $utama ? gambar((string) (q('SELECT file FROM proyek_gambar WHERE proyek_id = ? ORDER BY urutan LIMIT 1', [$utama['id']])->fetchColumn() ?: $utama['cover'])) : 'assets/img/kosong.svg';
Begitu pemilik menandai proyek baru sebagai unggulan di CMS, ponsel di hero langsung ikut berganti.
4. Keycap yang bisa ditekan dari keyboard
Keycap adalah tombol berlapis: lapisan luar berwarna gelap sebagai "badan" tombol, lapisan dalam berwarna sebagai tutupnya. Ruang di bawah tutup dibuat dengan padding-bottom, lalu dikurangi saat ditekan:
.key{padding:0 0 8px;background:#231942;border-radius:20px}
.key:active,.key.press{padding-bottom:2px}
Supaya lebih jenaka, tombol juga bisa ditekan dari keyboard. Ketik huruf F, maka keycap Figma, Framer, dan Flutter ikut turun:
addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || /input|textarea|select/i.test(document.activeElement?.tagName || '')) return;
const huruf = e.key.length === 1 ? e.key.toLowerCase() : '';
if (huruf) keys.filter(k => $('b', k).textContent.trim().toLowerCase().startsWith(huruf)).forEach(press);
});
Pengecekan activeElement penting agar pengunjung yang sedang mengetik di formulir kontak tidak memicu efek ini.
5. Testimoni seperti percakapan grup chat
Semua testimoni dirender di HTML, tetapi disembunyikan dengan CSS hanya jika JavaScript aktif (.js .chat .msg{display:none}). Mesin pencari dan pengunjung tanpa JS tetap bisa membaca semuanya. Saat kotak chat masuk layar, pesan dimunculkan satu per satu, didahului indikator mengetik:
const next = () => {
if (i >= msgs.length) { typing.classList.remove('on'); return; }
typing.classList.add('on');
body.appendChild(typing); // indikator selalu berada di bawah pesan terakhir
setTimeout(() => { msgs[i].classList.add('show'); i++; next(); }, 900);
};
IntersectionObserver memastikan animasi baru dimulai ketika pengunjung benar-benar melihat bagian itu, dan observer langsung diputus setelahnya agar tidak diputar ulang.
6. Garis perjalanan berliku yang tergambar sendiri
Riwayat kerja disusun bergantian kiri dan kanan di sepanjang sebuah path SVG berbentuk ular. Trik "menggambar" garis memakai pasangan stroke-dasharray dan stroke-dashoffset: buat satu garis putus-putus yang panjang segmennya sama dengan panjang path, lalu geser offset-nya.
const pathLen = path.getTotalLength();
path.style.strokeDasharray = `${pathLen}`;
path.style.strokeDashoffset = pathLen; // awalnya garis tidak terlihat
const paintPath = () => {
const r = pathWrap.getBoundingClientRect();
const p = Math.min(1, Math.max(0, (innerHeight * 0.75 - r.top) / r.height));
path.style.strokeDashoffset = pathLen * (1 - p); // makin jauh scroll, makin panjang garis
};
SVG-nya memakai preserveAspectRatio="none" agar ikut memanjang setinggi daftar pengalaman, dan vector-effect: non-scaling-stroke supaya ketebalan garis tetap 4 piksel walaupun SVG direntangkan. Di HP, garis dipindah ke tepi kiri dan semua kartu berada di sisi kanannya, sehingga tetap mudah dibaca di layar sempit. Karena perhitungan dilakukan di callback scroll yang sama dengan kartu bertumpuk dan progress bar, tidak ada listener tambahan yang membebani halaman.
Ringkasan
- Tombol timbul = bayangan padat tanpa blur, turun sejauh bayangannya saat ditekan.
- Kartu bertumpuk =
position: stickydengantopbertambah per kartu, lalu kartu yang tertimpa diskalakan. - Ponsel 3D = dua lapis
translateZ, sudut gabungan kursor + scroll, layar diambil otomatis dari proyek unggulan. - Keycap =
padding-bottomsebagai ruang tekan, bisa dipicu dari huruf keyboard. - Testimoni chat = konten tetap ada di HTML, dimunculkan bertahap dengan observer.
- Garis berliku =
stroke-dashoffsetyang dikurangi sesuai progres scroll.
Download source code lengkapnya
Template Memphis lengkap dengan CMS (profil, proyek + galeri, 70+ logo alat, pengalaman, layanan, testimoni, pesan masuk, dashboard pengunjung), data contoh, dan ilustrasi mockup siap pakai bisa kamu unduh gratis.
Download Source Code Template Portofolio Memphis + CMS di Papicode
Source code lengkapnya gratis
Lihat semua yang dibahas di artikel ini langsung berjalan.
Tutorial lainnya
Cara Membuat Sistem Tiket Travel dengan Pilih Kursi di Google Apps Script: Layout Armada, Anti Kursi Dobel, dan Kursi Kedaluwarsa
20 Oktober 2026
Cara Membuat Website Donasi Transparan dengan Google Apps Script: Kode Unik Transfer dan Laporan Penyaluran
20 Oktober 2026