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 uipkge-ng@latest add testimonial-marquee-scroll
Manifest:https://uipkge.dev/r/angular/testimonial-marquee-scroll.jsonInstalls to:src/app/components/blocks/testimonial-marquee-scroll/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

MarqueeQuote
interface MarqueeQuote {
  quote: string
  author: string
  role: string
  initials: string
}

Files installed (2)

  • src/app/components/blocks/testimonial-marquee-scroll/testimonial-marquee-scroll.component.ts5.3 kB
    // 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.
    // Port of the Vue/React block 1:1.
    import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    
    interface MarqueeQuote {
      quote: string
      author: string
      role: string
      initials: string
    }
    
    const quotes: MarqueeQuote[] = [
      { 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' },
    ]
    
    // Keyframes and the edge mask cannot be expressed as utilities; everything else
    // stays in the template. Matches the approach in logo-ticker-infinite.
    const MARQUEE_STYLES = `
    @keyframes marquee-scroll {
      0% { transform: translateX(0); }
      100% { transform: translateX(-50%); }
    }
    [data-slot="testimonial-marquee-scroll"] .marquee {
      mask-image: linear-gradient(to right, transparent, black 6rem, black calc(100% - 6rem), transparent);
    }
    [data-slot="testimonial-marquee-scroll"] .marquee__row { overflow: hidden; }
    [data-slot="testimonial-marquee-scroll"] .marquee__track {
      display: flex;
      width: max-content;
      gap: 1rem;
      animation: marquee-scroll 46s linear infinite;
    }
    [data-slot="testimonial-marquee-scroll"] .marquee__track--reverse { animation-direction: reverse; }
    [data-slot="testimonial-marquee-scroll"] .marquee__row:hover .marquee__track { animation-play-state: paused; }
    @media (prefers-reduced-motion: reduce) {
      [data-slot="testimonial-marquee-scroll"] .marquee__track { animation: none; }
      [data-slot="testimonial-marquee-scroll"] .marquee__row { overflow-x: auto; }
    }
    `
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-testimonial-marquee-scroll, [ui-testimonial-marquee-scroll]',
      standalone: true,
      // React renders the root <section> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiCardComponent, UiCardContentComponent],
      styles: [MARQUEE_STYLES],
      template: `
        <section data-slot="testimonial-marquee-scroll" [class]="rootClass">
          <div class="mx-auto max-w-6xl px-6 pt-20 lg:pt-28">
            <div class="max-w-2xl">
              <span ui-badge variant="secondary">What people say</span>
              <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">
            @for (row of rows; track $index; let 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. -->
                  @for (entry of doubled(row); track entry.author + '-' + $index) {
                    <div ui-card class="w-80 shrink-0">
                      <div ui-card-content class="p-5">
                        <blockquote class="text-sm leading-relaxed">“{{ entry.quote }}”</blockquote>
                        <div class="mt-4 flex items-center gap-3">
                          <ui-avatar class="size-8">
                            <ui-avatar-fallback class="text-xs">{{ entry.initials }}</ui-avatar-fallback>
                          </ui-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>
                      </div>
                    </div>
                  }
                </div>
              </div>
            }
          </div>
        </section>
      `,
    })
    export class UiTestimonialMarqueeScrollComponent {
      protected readonly rows: MarqueeQuote[][] = [quotes.slice(0, 4), quotes.slice(4)]
    
      @Input('class') className?: string
    
      get rootClass(): string {
        return cn('bg-background overflow-hidden', this.className)
      }
    
      doubled(row: MarqueeQuote[]): MarqueeQuote[] {
        return [...row, ...row]
      }
    }
    
  • src/app/components/blocks/testimonial-marquee-scroll/index.ts0.1 kB

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