Menu

Change language

GitHub repo

Testimonial — Marquee Scroll

blockmarketing

Two counter-scrolling marquee rows of quote cards that pause on hover and stop entirely under reduced-motion, with edge masks fading the cards into the page.

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/testimonial-marquee-scroll.json
Manifest:https://uipkge.dev/r/svelte/testimonial-marquee-scroll.jsonInstalls to:src/lib/components/blocks/testimonial-marquee-scroll/

Variants

Loading interactive previews…

Files installed (1)

  • src/lib/components/blocks/testimonial-marquee-scroll/TestimonialMarqueeScroll.svelte4 kB
    <script lang="ts" module>
      export interface TestimonialMarqueeScrollProps {
        class?: string
      }
    </script>
    
    <script lang="ts">
      import { Avatar, AvatarFallback } from '$lib/components/ui/avatar'
      import { Badge } from '$lib/components/ui/badge'
      import { Card, CardContent } from '$lib/components/ui/card'
    
      let { class: className }: TestimonialMarqueeScrollProps = $props()
    
      const quotes = [
        { quote: 'Close starts from a reconciled position now.', author: 'Erin Walsh', role: 'VP Finance', initials: 'EW' },
        {
          quote: 'The request queue went away and stayed away.',
          author: 'Daniel Brooks',
          role: 'Head of Analytics',
          initials: 'DB',
        },
        {
          quote: 'One pipeline definition, two teams, no argument.',
          author: 'Priya Raman',
          role: 'RevOps Director',
          initials: 'PR',
        },
        {
          quote: 'We deleted eleven spreadsheets in one quarter.',
          author: 'Marcus Ellery',
          role: 'Staff Engineer',
          initials: 'ME',
        },
        {
          quote: 'Auditors get a change log instead of a folder.',
          author: 'Sophie Lindqvist',
          role: 'Controller',
          initials: 'SL',
        },
        { quote: 'Permissions finally match the org chart.', author: 'Tom Fairbanks', role: 'IT Director', initials: 'TF' },
        {
          quote: 'Nobody has written a bespoke extract since March.',
          author: 'Anna Reyes',
          role: 'Data Lead',
          initials: 'AR',
        },
        { quote: 'Forecast review is about the forecast again.', author: 'Grace Whitlock', role: 'CFO', initials: 'GW' },
      ]
    
      const rows = [quotes.slice(0, 4), quotes.slice(4)]
    </script>
    
    <section data-slot="testimonial-marquee-scroll" class={['bg-background overflow-hidden', className]}>
      <div class="mx-auto max-w-6xl px-6 pt-20 lg:pt-28">
        <div class="max-w-2xl">
          <Badge variant="secondary">What people say</Badge>
          <h2 class="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Eight hundred teams, unprompted</h2>
        </div>
      </div>
    
      <!-- Edge masks fade the rows into the page so cards never end mid-cut. -->
      <div class="marquee relative mt-10 space-y-4 pb-20 lg:pb-28">
        {#each rows as row, index (index)}
          <div class="marquee__row">
            <div class="marquee__track" class:marquee__track--reverse={index === 1}>
              <!-- Duplicated once so the loop has an identical second half to scroll into. -->
              {#each [...row, ...row] as entry, i (`${entry.author}-${i}`)}
                <Card class="w-80 shrink-0">
                  <CardContent class="p-5">
                    <blockquote class="text-sm leading-relaxed">“{entry.quote}”</blockquote>
                    <div class="mt-4 flex items-center gap-3">
                      <Avatar class="size-8">
                        <AvatarFallback class="text-xs">{entry.initials}</AvatarFallback>
                      </Avatar>
                      <div class="min-w-0">
                        <p class="truncate text-xs font-medium">{entry.author}</p>
                        <p class="text-muted-foreground truncate text-xs">{entry.role}</p>
                      </div>
                    </div>
                  </CardContent>
                </Card>
              {/each}
            </div>
          </div>
        {/each}
      </div>
    </section>
    
    <style>
      /* Keyframes and the edge mask cannot be expressed as utilities; everything else
         stays in the template. Matches the approach in logo-ticker-infinite. */
      @keyframes marquee-scroll {
        0% {
          transform: translateX(0);
        }
        100% {
          transform: translateX(-50%);
        }
      }
    
      .marquee {
        mask-image: linear-gradient(to right, transparent, black 6rem, black calc(100% - 6rem), transparent);
      }
    
      .marquee__row {
        overflow: hidden;
      }
    
      .marquee__track {
        display: flex;
        width: max-content;
        gap: 1rem;
        animation: marquee-scroll 46s linear infinite;
      }
    
      .marquee__track--reverse {
        animation-direction: reverse;
      }
    
      .marquee__row:hover .marquee__track {
        animation-play-state: paused;
      }
    
      @media (prefers-reduced-motion: reduce) {
        .marquee__track {
          animation: none;
        }
        .marquee__row {
          overflow-x: auto;
        }
      }
    </style>
    

Raw manifest:https://uipkge.dev/r/svelte/testimonial-marquee-scroll.json