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@latest add https://uipkge.dev/r/lit/testimonial-marquee-scroll.json
Manifest:https://uipkge.dev/r/lit/testimonial-marquee-scroll.jsonInstalls to:src/uip/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.

HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-testimonial-marquee-scroll': UipTestimonialMarqueeScroll
  }
}

npm dependencies

Files installed (1)

  • src/uip/blocks/testimonial-marquee-scroll/testimonial-marquee-scroll.ts5.4 kB
    import { LitElement, css, html } from 'lit'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/avatar/avatar'
    import '../../components/badge/badge'
    import '../../components/card/card'
    
    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)]
    
    /**
     * <uip-testimonial-marquee-scroll> — the registry TestimonialMarqueeScroll
     * block as a web component.
     *
     * Class strings and copy are React's TestimonialMarqueeScroll verbatim; the
     * track duplicates its children to loop seamlessly, exactly like React.
     * React's injected <style> keyframes and edge mask are copied into static
     * styles — the one allowed CSS exception besides the :host one-liner.
     */
    export class UipTestimonialMarqueeScroll extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      static styles = [
        tailwind,
        // React-injected keyframes, copied verbatim (scoped to this shadow root).
        css`
          :host {
            display: block;
          }
          @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;
            }
          }
        `,
      ]
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'testimonial-marquee-scroll')
      }
    
      render() {
        return html`
          <section data-slot="testimonial-marquee-scroll" class="bg-background overflow-hidden">
            <div class="mx-auto max-w-6xl px-6 pt-20 lg:pt-28">
              <div class="max-w-2xl">
                <uip-badge variant="secondary">What people say</uip-badge>
                <h2 class="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Eight hundred teams, unprompted</h2>
              </div>
            </div>
    
            <div class="marquee relative mt-10 space-y-4 pb-20 lg:pb-28">
              ${rows.map(
                (row, index) => html`
                  <div class="marquee__row">
                    <div class=${`marquee__track ${index === 1 ? 'marquee__track--reverse' : ''}`}>
                      ${[...row, ...row].map(
                        (entry) => html`
                          <uip-card class="w-80 shrink-0">
                            <uip-card-content class="p-5">
                              <blockquote class="text-sm leading-relaxed">“${entry.quote}”</blockquote>
                              <div class="mt-4 flex items-center gap-3">
                                <uip-avatar class="[&::part(base)]:size-8">
                                  <uip-avatar-fallback class="[&::part(base)]:text-xs">
                                    ${entry.initials}
                                  </uip-avatar-fallback>
                                </uip-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>
                            </uip-card-content>
                          </uip-card>
                        `,
                      )}
                    </div>
                  </div>
                `,
              )}
            </div>
          </section>
        `
      }
    }
    
    customElements.get('uip-testimonial-marquee-scroll') ||
      customElements.define('uip-testimonial-marquee-scroll', UipTestimonialMarqueeScroll)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-testimonial-marquee-scroll': UipTestimonialMarqueeScroll
      }
    }
    

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