← Semua tutor

Hasil akhir · testimoni yang bergantian

Satu kutipan jadi setumpuk cerita.

Sebelum · daftar klien + kutipan

Sesudah · tumpukan dari asset.inwebsite.online

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

Yang perlu ada sebelum mulai.

  • Editor kode apa saja. VS Code, Cursor, atau yang kamu biasa pakai.
  • Node.js dan proyek React. Snippet berjalan di React dan memakai kelas Tailwind CSS.
  • AI agent (opsional, tapi paling cepat). Di panduan ini contohnya Claude Code.
  • Akun InWebAsset. Mencari dan merencanakan gratis tanpa login, tapi kode hanya bisa diambil setelah login.
Perlu loginTanpa login kamu bisa menjelajah, melihat pratinjau, dan memakai MCP untuk mencari. Tombol Copy kode dan API key MCP baru bisa dipakai setelah Sign in.
Batas harian: 5 salinanAkun gratis boleh menyalin kode snippet 5 kali sehari. Setiap salinan dihitung, termasuk snippet yang pernah kamu salin, dan kuotanya dipakai bersama antara situs dan MCP. Akun Pro tanpa batas.

Mencari, melihat pratinjau, dan perencanaan lewat agent tidak memakai kuota.

Langkah 2 · Jelajahi katalog

Pilih desain yang mau diambil.

Buka asset.inwebsite.online. Katalognya berisi 172 snippet. Persempit lewat kategori, tempat pemakaian, atau kata kunci.

  • Pilih kategori Cards & Sections.
  • Di Where it goes, pilih Social proof.
  • Ketik testimonial di kolom cari. Tersisa tiga snippet.
  • Arahkan kursor ke kartu untuk melihat pratinjau bergerak, lalu klik Testimonial Stack.
REKAMAN · KATALOG
REKAMAN · HALAMAN SNIPPET

Langkah 3 · Buka halaman snippet

Lihat dulu, baru ambil.

  • Tab Preview: tumpukan kartu berganti dan titik di bawahnya terisi seperti timer. Panah dan titik bisa diklik.
  • Tab Code: sebelum login, kodenya terkunci. Muncul tombol Sign in to copy.
  • Install: npm i gsap @gsap/react. Syaratnya React + Tailwind CSS.
  • Assets in this snippet: lima avatar ilustrasi dari keluarga avatars-flat, hanya untuk contoh ulasan. Untuk ulasan sungguhan, pakai foto asli atau biarkan kosong supaya yang tampil inisial.
  • For agents: ada tombol Copy prompt dan tautan registry untuk AI agent.
IngatUntuk menyalin kode kamu harus login, dan akun gratis dibatasi 5 salinan per hari.

Langkah 4 · Ambil kodenya

Dua jalur, pilih satu.

JALUR A · LEBIH CEPATPakai MCP lewat AI agent

Agent mencari, memilih, dan memasang snippet sendiri. Buka halaman For agents, login, buat API key, lalu salin perintahnya.

$claude mcp add --transport http InWebAsset https://asset.inwebsite.online/mcp --header "Authorization: Bearer <API key-mu>"

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.

JALUR B · MANUALSalin source code

Login, buka halaman snippet, tekan Copy di tab Code. Simpan sebagai komponen, lalu pasang sendiri atau kirim ke agent.

$ npm i gsap @gsap/react

Simpan sebagai components/TestimonialStack.jsx. Tiap Copy memakai 1 dari 5 jatah harian akun gratis.

REKAMAN · HALAMAN FOR AGENTS

Langkah 5 · Beri konteks ke agent

Bilang data mana yang dipakai.

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.

Bagian web section .testiFile Testimonials.jsxData WORKS[].testimonialAvatar tanda proyek klienWarna #e8461eStack React + CSS biasa
Kalau pakai Jalur B: tempel kodenya setelah prompt ini
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>
contoh · Claude Code (Jalur A)
# 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

Snippet itu titik awal.

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.
components/TestimonialStack.jsx · terpasang di web contoh
/*
 * 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

Sebelum vs sesudah.

Sebelum

Sesudah

Kartu kanan hidup: tumpukannya berganti sendiri. Arahkan kursor ke dalamnya untuk berhenti, lalu klik panah atau titik.

01 · SIAPKANEditor, React, agent, akun (login)
02 · JELAJAHCards & Sections → Social proof
03 · BUKAPratinjau, install, kode (login)
04 · PASANGBeri data, batas (tanpa rating), dan warna ke agent