Hasil akhir · testimoni yang bergantian
Dua web yang sama, bedanya satu snippet. Di kanan, kartu depan miring keluar tiap lima detik dan kartu di belakangnya naik menggantikan. Arahkan kursor ke kartu kanan untuk menghentikannya, lalu pakai panah.
Langkah 1 · Siapkan dulu
Mencari, melihat pratinjau, dan perencanaan lewat agent tidak memakai kuota.
Langkah 2 · Jelajahi katalog
Buka asset.inwebsite.online. Katalognya berisi 172 snippet. Persempit lewat kategori, tempat pemakaian, atau kata kunci.
Langkah 3 · Buka halaman snippet
npm i gsap @gsap/react. Syaratnya React + Tailwind CSS.avatars-flat, hanya untuk contoh ulasan. Untuk ulasan sungguhan, pakai foto asli atau biarkan kosong supaya yang tampil inisial.Langkah 4 · Ambil kodenya
Agent mencari, memilih, dan memasang snippet sendiri. Buka halaman For agents, login, buat API key, lalu salin perintahnya.
Perintah di atas untuk Claude Code. Halaman itu juga punya versi Claude Desktop, Cursor, dan VS Code. Jangan pernah membagikan API key-mu.
Mencari dan perencanaan tanpa kunci. Kodenya (get_snippet) baru keluar dengan API key, dan memakai jatah 5 salinan akun gratis.
Login, buka halaman snippet, tekan Copy di tab Code. Simpan sebagai komponen, lalu pasang sendiri atau kirim ke agent.
Simpan sebagai components/TestimonialStack.jsx. Tiap Copy memakai 1 dari 5 jatah harian akun gratis.
Langkah 5 · Beri konteks ke agent
Contoh di katalog memakai lima ulasan karangan, rating bintang, dan angka "4.9 dari 2.300+ ulasan". Web contohnya sudah punya delapan testimoni klien di data proyeknya, tanpa rating. Kalau tidak diberi tahu, agent bisa saja membawa bintang dan angka ulasan yang tidak pernah ada.
Ini kode komponen dari asset.inwebsite.online. Pasang di section testimoni (src/sections/Testimonials.jsx) menggantikan daftar klien dan kartu kutipan yang ada; judul section tetap. Ambil data dari WORKS di src/content/works.js (testimonial.quote, name, role, plus nama proyek dan kota). Jangan mengarang rating atau jumlah ulasan: hapus bintang dan baris summary. Avatar pakai WorkMark proyeknya, bukan foto orang. Kolom kiri menampilkan nama proyek aktif, kota, dan tautan "Lihat proyeknya". Kartu berganti sendiri tiap 5 detik dan berhenti saat kursor masuk. Proyek saya React + CSS biasa tanpa Tailwind: pasang Tailwind v4 tanpa preflight. Aksen #e8461e, font kutipan var(--serif). <tempel kode di sini>
# sekali saja: sambungkan MCP $ claude mcp add --transport http InWebAsset https://asset.inwebsite.online/mcp --header "Authorization: Bearer <API key-mu>" # masuk ke folder proyek, jalankan agent $ claude > Pakai InWebAsset MCP. Ambil snippet cards-sections/testimonial-stack dan pasang di section testimoni (src/sections/Testimonials.jsx) menggantikan daftar klien dan kartu kutipan; judul section tetap. Data dari WORKS di src/content/works.js. Jangan mengarang rating atau jumlah ulasan, dan jangan pakai avatar orang: pakai WorkMark proyeknya. Tumpukan berganti sendiri tiap 5 detik sampai kursor masuk. Proyek saya React + CSS biasa tanpa Tailwind, jadi pasang Tailwind v4 tanpa preflight. Kirim look situs (terang, aksen #e8461e, sudut bulat, gerak tenang) ke get_snippet supaya dapat daftar penyesuaian. Cek dulu dengan includeCode:false, lalu ambil kodenya sekali. Jangan ubah bagian lain.
Yang kamu dapat
Kode di kanan adalah komponen yang terpasang di web contoh. Ini yang diubah dari versi asli di katalog:
datalima ulasan contoh diganti delapan testimoni dari data proyek web contoh. Tidak ada kutipan baru yang dikarang.ratingbintang dan baris "4.9 average from 2,300+ reviews" dihapus, karena web contoh tidak punya data rating.avataravatar ilustrasi diganti tanda proyek klien (WorkMark) di atas warna mereknya, supaya tidak ada wajah yang seolah-olah orang sungguhan.tata letaktumpukan dilebarkan ke kolom kanan selebar kartu lama. Kolom kiri berisi nomor, nama proyek dengan fontnya, sektor, tautan Lihat proyeknya, lalu panah dan titik.warnaoranye #d9773a dan kuning bintang diganti aksen situs #e8461e; gradasi warna-warni contoh ikut hilang. Sesuai saran "Fit this site" dari get_snippet.autoPlaydinyalakan, tiap kartu 5 detik, sama seperti testimoni lama yang juga berganti sendiri. Berhenti begitu kursor atau fokus masuk.gerakpantulan back.out di avatar diganti power3.out. Cabang prefers-reduced-motion dibuang mengikuti aturan web contoh.Tailwindweb contoh tidak memakai Tailwind. Dipasang v4 tanpa preflight (hanya theme + utilities); tombol diberi border-0 supaya gaya bawaan browser hilang./*
* Testimonial Stack — diadaptasi untuk Tandarupa
* Asal: https://asset.inwebsite.online/snippets/cards-sections/testimonial-stack (v1.1.1)
* Perubahan: data dari WORKS (8 klien), tanpa bintang rating dan angka ulasan, avatar = tanda
* proyek klien (bukan foto orang), aksen #e8461e, dua kolom (panel klien + tumpukan),
* tumpukan berganti sendiri tiap 5 detik sampai kursor masuk.
*/
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(useGSAP)
const ACCENT = '#e8461e'
// Posisi kartu menurut kedalaman di tumpukan (0 = depan). Kartu sesudah yang ketiga disembunyikan di belakang.
const depthState = (depth) => {
const d = Math.min(depth, 3)
return {
y: d * 16,
scale: 1 - d * 0.055,
xPercent: 0,
rotation: 0,
autoAlpha: depth < 3 ? 1 : 0,
zIndex: 20 - depth,
}
}
function Arrow({ dir }) {
return (
<svg viewBox="0 0 16 16" aria-hidden className="size-4">
<path
d={dir === 'next' ? 'M3 8h9.5M8.5 4l4 4-4 4' : 'M13 8H3.5M7.5 4l-4 4 4 4'}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
const pad = (n) => String(n).padStart(2, '0')
// testimonials: { quote, name, role, company, sector, city, font, href, mark, markBg }
export default function TestimonialStack({ testimonials, interval = 5, autoPlay = true, onChange, className = '' }) {
const root = useRef(null)
const prevActive = useRef(null)
const dir = useRef(1)
const idle = useRef(null)
const [active, setActive] = useState(0)
const [playing, setPlaying] = useState(autoPlay)
const count = testimonials.length
const current = testimonials[active]
const go = (next, direction) => {
dir.current = direction
setActive(((next % count) + count) % count)
}
// Menata tumpukan untuk kartu aktif. Kartu yang lewat dari depan miring keluar, lalu masuk lagi di belakang.
useGSAP(
(context) => {
const q = gsap.utils.selector(root)
const cards = q('.tsk-card')
const shades = q('.tsk-shade')
const from = prevActive.current
prevActive.current = active
const depthOf = (i, front) => (i - front + count) % count
// Tween di luar context (ignore), supaya autoPlay yang tidak berhenti tidak menumpuk isi context.
context.ignore(() => {
q('.tsk-dot').forEach((dot, i) => {
const vars = { width: i === active ? 28 : 8 }
if (from === null) gsap.set(dot, vars)
else gsap.to(dot, { ...vars, duration: 0.5, ease: 'power3.out' })
})
gsap.set(q('.tsk-fill'), { scaleX: (i) => (i === active && !idle.current ? 1 : 0) })
if (from !== null) gsap.fromTo(q('.tsk-meta'), { y: 10, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.5, ease: 'power3.out', delay: 0.2 })
if (from === null) {
// Remount Strict Mode menjalankan ini lagi saat animasi masuk pertama masih berjalan.
gsap.killTweensOf([...cards, ...shades])
cards.forEach((card, i) => gsap.set(card, depthState(depthOf(i, active))))
shades.forEach((shade, i) => gsap.set(shade, { opacity: Math.min(depthOf(i, active), 3) * 0.28 }))
// Masuk: tumpukan naik sedikit dari bawah, kartu belakang lebih dulu.
gsap.from(cards.slice().reverse(), { y: '+=36', autoAlpha: 0, duration: 0.9, ease: 'power3.out', stagger: 0.07 })
return
}
// Klik cepat kedua mengambil alih gerakan yang masih berjalan.
gsap.killTweensOf([...cards, ...shades])
// Tanda klien di kartu depan yang baru membesar pelan saat kartunya mendarat.
const frontAvatar = cards[active]?.querySelector('.tsk-avatar')
if (frontAvatar) gsap.fromTo(frontAvatar, { scale: 0.8 }, { scale: 1, duration: 0.55, ease: 'power3.out', delay: 0.25, overwrite: true })
const forward = dir.current === 1
cards.forEach((card, i) => {
const oldDepth = depthOf(i, from)
const newDepth = depthOf(i, active)
const target = depthState(newDepth)
const shade = shades[i]
const shadeTo = { opacity: Math.min(newDepth, 3) * 0.28 }
if (forward && newDepth > oldDepth) {
// Keluar dari depan: miring dan bergeser, lalu menyelip di belakang tumpukan.
gsap
.timeline({ delay: oldDepth * 0.08 })
.set(card, { zIndex: 30 + count - oldDepth })
.to(card, { xPercent: -112, rotation: -9, y: -14, duration: 0.6, ease: 'power2.in' })
.set(card, { zIndex: target.zIndex })
.set(shade, shadeTo)
.fromTo(
card,
{ xPercent: 0, rotation: 0, y: target.y + 26, scale: target.scale - 0.04, autoAlpha: 0 },
{ ...target, duration: 0.6, ease: 'power3.out', immediateRender: false },
)
} else if (!forward && newDepth < oldDepth) {
// Kembali ke depan dari sisi tempat ia keluar.
gsap.set(card, { zIndex: target.zIndex })
gsap.set(shade, shadeTo)
gsap.fromTo(card, { xPercent: -112, rotation: -9, y: -14, scale: 1, autoAlpha: 0 }, { ...target, duration: 0.75, ease: 'power3.out' })
} else {
// Kartu lain maju (atau mundur) satu langkah dengan jeda kecil, supaya tumpukan terasa berlapis.
const delay = forward ? 0.16 + newDepth * 0.05 : newDepth * 0.04
gsap.set(card, { zIndex: target.zIndex })
gsap.to(card, { ...target, duration: 0.75, ease: 'power3.out', delay })
gsap.to(shade, { ...shadeTo, duration: 0.6, ease: 'power2.out', delay })
}
})
})
},
{ scope: root, dependencies: [active, count], revertOnUpdate: false },
)
useEffect(
() => () => {
prevActive.current = null
},
[],
)
// Berganti sendiri: titik aktif terisi seperti timer, lalu kartu berikutnya maju.
useGSAP(
(context) => {
if (!autoPlay) {
setPlaying(false)
return
}
const q = gsap.utils.selector(root)
let front = 0
idle.current = gsap.timeline({ delay: 0.6, repeat: -1 })
idle.current
.add(() =>
context.ignore(() => {
gsap.set(q('.tsk-fill'), { scaleX: 0 })
gsap.fromTo(q('.tsk-fill')[front], { scaleX: 0 }, { scaleX: 1, duration: interval, ease: 'none' })
}),
)
.add(() => {
front = (front + 1) % count
go(front, 1)
}, interval)
return () => {
idle.current = null
}
},
{ scope: root, dependencies: [autoPlay, interval, count] },
)
// Interaksi pertama menghentikan putaran otomatis.
const stopIdle = () => {
if (!idle.current) return
idle.current.kill()
idle.current = null
setPlaying(false)
const fills = root.current?.querySelectorAll('.tsk-fill')
if (fills) gsap.set(fills, { scaleX: (i) => (i === active ? 1 : 0), overwrite: true })
}
const choose = (next, direction) => {
stopIdle()
const index = ((next % count) + count) % count
if (index === active) return
go(index, direction)
onChange?.(index)
}
const onKeyDown = (e) => {
if (e.key === 'ArrowRight') choose(active + 1, 1)
else if (e.key === 'ArrowLeft') choose(active - 1, -1)
}
const iconButton =
'grid size-11 cursor-pointer place-items-center rounded-full border-0 bg-white text-[#2a2825] shadow-sm ring-1 ring-black/[0.08] transition-colors hover:bg-[#141311] hover:text-[#f1ede4] focus-visible:ring-2 focus-visible:ring-[#e8461e] focus-visible:outline-none'
return (
<div
ref={root}
role="region"
aria-roledescription="carousel"
aria-label="Testimoni klien"
onPointerEnter={stopIdle}
onFocus={stopIdle}
onKeyDown={onKeyDown}
className={`mx-auto grid w-full max-w-[1440px] gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.6fr)] md:gap-16 ${className}`}
>
<div className="order-2 flex flex-col justify-between gap-8 md:order-1 md:border-t md:border-[rgba(20,19,17,0.14)] md:pt-6">
<div className="tsk-meta grid gap-3">
<p className="m-0 text-[13px] tracking-[0.08em] text-[#5b5750]" style={{ fontFamily: 'var(--mono)' }}>
{pad(active + 1)} / {pad(count)} · {current.city.toUpperCase()}
</p>
<p className={`m-0 text-[clamp(30px,3vw,44px)] leading-none text-[#141311] f-${current.font}`}>{current.company}</p>
<p className="m-0 text-[17px] text-[#5b5750]">{current.sector}</p>
<Link to={current.href} className="mt-2 justify-self-start text-[13px] tracking-[0.06em] text-[#e8461e] uppercase" style={{ fontFamily: 'var(--mono)' }}>
Lihat proyeknya →
</Link>
</div>
<div className="flex items-center gap-3">
<button type="button" aria-label="Testimoni sebelumnya" onClick={() => choose(active - 1, -1)} className={iconButton}>
<Arrow dir="prev" />
</button>
<div className="flex items-center gap-1.5">
{testimonials.map((t, i) => (
<button
key={t.name + i}
type="button"
aria-label={`Tampilkan testimoni ${i + 1}`}
aria-current={i === active || undefined}
onClick={() => choose(i, i > active ? 1 : -1)}
className="tsk-dot relative h-2 w-2 cursor-pointer overflow-hidden rounded-full border-0 bg-[#141311]/15 p-0 focus-visible:ring-2 focus-visible:ring-[#e8461e] focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span aria-hidden className="tsk-fill absolute inset-0 origin-left scale-x-0 rounded-full" style={{ background: ACCENT }} />
</button>
))}
</div>
<button type="button" aria-label="Testimoni berikutnya" onClick={() => choose(active + 1, 1)} className={iconButton}>
<Arrow dir="next" />
</button>
</div>
</div>
<div aria-live={playing ? 'off' : 'polite'} className="order-1 grid pb-12 md:order-2">
{testimonials.map((t, i) => {
const depth = (i - active + count) % count
return (
<figure
key={t.name + i}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} dari ${count}`}
aria-hidden={depth !== 0}
className="tsk-card relative m-0 flex flex-col overflow-hidden rounded-[28px] bg-[#fffdf9] p-7 shadow-[0_1px_2px_rgba(28,25,23,0.06),0_18px_40px_-18px_rgba(28,25,23,0.28)] ring-1 ring-[#1c1917]/[0.07] [grid-area:1/1] md:p-12"
style={{ transformOrigin: '50% 100%' }}
>
<svg viewBox="0 0 40 30" aria-hidden className="h-[30px] w-[40px]" style={{ color: ACCENT }}>
<path
fill="currentColor"
d="M9.3 30C4 30 .6 26.3.6 20.7.6 11.4 7.2 3.2 16.4.3l1.5 3C12.6 5.5 9 9.4 8.3 14c.6-.3 1.5-.5 2.6-.5 4.4 0 7.6 3.3 7.6 7.8 0 5-3.9 8.7-9.2 8.7zm21.6 0c-5.3 0-8.7-3.7-8.7-9.3 0-9.3 6.6-17.5 15.8-20.4l1.5 3C34.2 5.5 30.6 9.4 29.9 14c.6-.3 1.5-.5 2.6-.5 4.4 0 7.6 3.3 7.6 7.8 0 5-3.9 8.7-9.2 8.7z"
/>
</svg>
<blockquote
className="m-0 mt-6 mb-10 text-[clamp(26px,2.9vw,42px)] leading-[1.15] tracking-[-0.01em] text-pretty text-[#141311]"
style={{ fontFamily: 'var(--serif)' }}
>
{t.quote}
</blockquote>
<figcaption className="mt-auto flex items-center gap-4 border-t border-[#1c1917]/[0.08] pt-6">
<span aria-hidden className="tsk-avatar grid size-12 shrink-0 place-items-center rounded-full shadow-sm ring-2 ring-white" style={{ background: t.markBg }}>
{t.mark}
</span>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[15px] font-semibold text-[#141311]">{t.name}</span>
<span className="mt-0.5 block truncate text-[14px] text-[#5b5750]">{t.role}</span>
</span>
</figcaption>
{/* Menggelapkan kartu yang lebih belakang, supaya tumpukan terbaca berlapis. */}
<span aria-hidden className="tsk-shade pointer-events-none absolute inset-0 bg-[#e7e0d2] opacity-0" />
</figure>
)
})}
</div>
</div>
)
}
Langkah 6 · Bandingkan
Kartu kanan hidup: tumpukannya berganti sendiri. Arahkan kursor ke dalamnya untuk berhenti, lalu klik panah atau titik.
Mau section testimoni seperti ini tanpa memasangnya sendiri? Tim InWebsite bisa bikinin web-mu.
Lihat paket InWebsite →