Component · Feature sections
Feature Alternating
Alternating Vue 3 feature rows that zigzag text and CSS-drawn illustrations, with bullets and links per row. Tailwind CSS, dark mode, no images needed.
"Trusted by" logo cloud for Vue 3 with a bordered grid or an endless marquee, using text wordmarks and SVG marks. Tailwind CSS, dark mode, reduced motion.
by Vueplayv1.0.0Updated 3 hours ago
A social-proof strip for landing pages: a short line like "Trusted by 2,000+ teams" followed by the brands that use your product, either as a tidy bordered grid or as a slowly scrolling marquee.
The placeholder brands are fictional wordmarks built from text and simple SVG shapes, each with its own type treatment, so the section looks finished before you have real logos. Swap in your customers by passing logos, or replace the markup with your own SVG files. Add an href to link a logo to its case study.
The marquee is animated with the Web Animations API, so there are no global keyframes to add. It pauses on hover and focus, stops entirely when the visitor prefers reduced motion, and hides the duplicated loop from screen readers.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
/** Simple geometric marks drawn next to each wordmark. */
export type LogoMark = 'circle' | 'triangle' | 'square' | 'hexagon' | 'bolt' | 'wave' | 'ring' | 'none'
export interface Logo {
name: string
mark?: LogoMark
/** Optional link, e.g. to a case study. */
href?: string
}
const props = withDefaults(defineProps<{
heading?: string
logos?: Logo[]
/** 'grid' shows a static bordered grid; 'marquee' scrolls the logos in an endless loop. */
variant?: 'grid' | 'marquee'
/** Seconds for one full marquee loop. */
speed?: number
/** Pause the marquee while hovered or focused. */
pauseOnHover?: boolean
}>(), {
heading: 'Trusted by 2,000+ product teams, from two-person startups to public companies',
variant: 'grid',
speed: 40,
pauseOnHover: true,
logos: () => [
{ name: 'Northwind', mark: 'triangle' },
{ name: 'Halcyon', mark: 'circle' },
{ name: 'Copperleaf', mark: 'hexagon' },
{ name: 'Voltic', mark: 'bolt' },
{ name: 'Tidewater', mark: 'wave' },
{ name: 'Orbitly', mark: 'ring' },
{ name: 'Blockhaus', mark: 'square' },
{ name: 'Mistral Bay', mark: 'none' },
],
})
// Each wordmark gets its own type treatment so the row reads like a set of distinct brands.
const typeStyles = [
'font-bold tracking-tight',
'font-serif font-semibold italic',
'font-extrabold uppercase tracking-wider text-[0.95em]',
'font-black italic tracking-tighter',
'font-medium lowercase tracking-tight',
'font-mono font-bold',
'font-extrabold uppercase tracking-[0.18em] text-[0.85em]',
'font-serif font-bold',
]
const marks: Record<Exclude<LogoMark, 'none'>, string> = {
circle: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
triangle: 'M12 3 22 20H2L12 3Z',
square: 'M4 4h16v16H4z',
hexagon: 'M12 2.5 20.5 7.25v9.5L12 21.5l-8.5-4.75v-9.5L12 2.5Z',
bolt: 'M13.5 2 4 14h7l-1.5 8L20 10h-7l.5-8Z',
wave: 'M2 14c2.5-4 5-4 7.5 0s5 4 7.5 0 3.5-3 5-2.5V20H2v-6Z',
ring: 'M12 22a10 10 0 1 1 0-20 10 10 0 0 1 0 20Zm0-5a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z',
}
// Marquee: animated with the Web Animations API so the component needs no global CSS keyframes.
const track = ref<HTMLElement>()
let animation: Animation | undefined
function startMarquee() {
animation?.cancel()
animation = undefined
if (props.variant !== 'marquee' || !track.value) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
animation = track.value.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(-50%)' }],
{ duration: props.speed * 1000, iterations: Infinity, easing: 'linear' },
)
}
function pause() { if (props.pauseOnHover) animation?.pause() }
function resume() { animation?.play() }
onMounted(startMarquee)
watch(() => [props.variant, props.speed, props.logos], () => requestAnimationFrame(startMarquee))
onBeforeUnmount(() => animation?.cancel())
</script>
<template>
<section class="px-6 py-16 sm:py-20 lg:px-8">
<h2 v-if="heading" class="mx-auto max-w-2xl text-center text-sm font-semibold text-balance text-gray-600 dark:text-gray-400">{{ heading }}</h2>
<!-- Grid -->
<ul
v-if="variant === 'grid'"
class="mx-auto mt-10 grid max-w-6xl grid-cols-2 gap-px overflow-hidden rounded-2xl bg-gray-200 ring-1 ring-gray-200 md:grid-cols-4 dark:bg-gray-800 dark:ring-gray-800"
>
<li v-for="(logo, i) in logos" :key="logo.name" class="flex bg-white dark:bg-gray-950">
<component
:is="logo.href ? 'a' : 'div'"
:href="logo.href"
class="group flex h-24 w-full items-center justify-center gap-2 px-4 text-gray-500 transition hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-600 sm:h-28 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-white"
>
<svg v-if="logo.mark && logo.mark !== 'none'" viewBox="0 0 24 24" aria-hidden="true" class="size-6 shrink-0 fill-current">
<path :d="marks[logo.mark]" fill-rule="evenodd" />
</svg>
<span class="text-lg sm:text-xl" :class="typeStyles[i % typeStyles.length]">{{ logo.name }}</span>
</component>
</li>
</ul>
<!-- Marquee -->
<div
v-else
class="relative mx-auto mt-10 max-w-6xl overflow-hidden mask-[linear-gradient(to_right,transparent,black_12%,black_88%,transparent)]"
@mouseenter="pause"
@mouseleave="resume"
@focusin="pause"
@focusout="resume"
>
<div ref="track" class="flex w-max">
<!-- The list is rendered twice so translating by -50% loops seamlessly; the copy is hidden from assistive tech. -->
<ul v-for="copy in 2" :key="copy" class="flex shrink-0 items-center gap-12 pr-12 sm:gap-16 sm:pr-16" :aria-hidden="copy === 2 ? 'true' : undefined">
<li v-for="(logo, i) in logos" :key="logo.name">
<component
:is="logo.href ? 'a' : 'div'"
:href="logo.href"
:tabindex="copy === 2 && logo.href ? -1 : undefined"
class="flex items-center gap-2 whitespace-nowrap text-gray-500 transition hover:text-gray-900 focus-visible:rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-sky-600 dark:text-gray-400 dark:hover:text-white"
>
<svg v-if="logo.mark && logo.mark !== 'none'" viewBox="0 0 24 24" aria-hidden="true" class="size-6 shrink-0 fill-current">
<path :d="marks[logo.mark]" fill-rule="evenodd" />
</svg>
<span class="text-xl" :class="typeStyles[i % typeStyles.length]">{{ logo.name }}</span>
</component>
</li>
</ul>
</div>
</div>
<div v-if="$slots.default" class="mt-10 text-center">
<slot />
</div>
</section>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| heading | string | 'Trusted by 2,000+ product teams…' | Caption above the logos. Empty string hides it. |
| logos | Logo[] | 8 fictional brands | Logos with name, optional mark ('circle' | 'triangle' | 'square' | 'hexagon' | 'bolt' | 'wave' | 'ring' | 'none') and optional href. |
| variant | 'grid' | 'marquee' | 'grid' | Static bordered grid or endlessly scrolling row. |
| speed | number | 40 | Seconds for one full marquee loop. Higher is slower. |
| pauseOnHover | boolean | true | Pause the marquee while it is hovered or focused. |
| Slot | Description |
|---|---|
| default | Content centred under the logos, e.g. a link to customer stories. |
Component · Feature sections
Alternating Vue 3 feature rows that zigzag text and CSS-drawn illustrations, with bullets and links per row. Tailwind CSS, dark mode, no images needed.
Component · Feature sections
Responsive Vue 3 feature grid with a heading, inline SVG icons and plain or card variants in 2, 3 or 4 columns. Tailwind CSS, dark mode, typed data.
Component · Feature sections
Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.
Component · Hero sections
Centered Vue 3 hero with an announcement pill, gradient headline, grid background, two CTAs and a row of wordmarks. Tailwind CSS with dark mode.