Logo Belt
Real names or logos as one line of type, a slow marquee or a wall; never invented, fill-ins until given.
npx shadcn@latest add @vibebox/logos-beltControls
<LogosBelt />About
Replaces the site kit's highlights row (it set what the business offers in large type instead of a fake logo bar) and adds the marquee and wall for real logos. The honesty rule is the point: no invented clients.
Use it when
- The business carries brands, has partners, or wants its offer stated plainly.
- Between the hero and the first content section.
Skip it when
- There are no real names to show yet and the offer already appears in the hero: leave it out.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/logos-belt - 3Use it:
import { LogosBelt } from "@/components/vibebox/logos-belt"; import { home } from "@/content/pages/home"; <LogosBelt content={home.logos} variant="type-line" id="logos" />
Code
2 filescomponents/vibebox/logos-belt/index.tsx
/**
* VibeBox Motion · Logo Belt
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { Container, FillIn, Media, REVEAL, Section, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { isFillIn } from "@/lib/vibebox/content/runtime";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";
/*
* Names or logos the business really works with (or what it offers), as one calm line of type, a
* slow marquee, or a wall. Never invented: until the client gives them, names are fill-ins.
* The marquee repeats the list once for a seamless loop; the copy is hidden from screen readers.
*/
const STYLES = `
@keyframes vb-logos-belt { to { transform: translate3d(-50%, 0, 0); } }
@media (prefers-reduced-motion: no-preference) {
[data-vb="logos-belt"] [data-vb-belt] { animation: vb-logos-belt 48s linear infinite; }
[data-vb="logos-belt"] [data-vb-belt]:hover { animation-play-state: paused; }
}
:where([data-motion="0"], [data-motion="1"]) [data-vb="logos-belt"] [data-vb-belt] { animation: none; }
/* Still, the belt becomes a centred, wrapping list, so no name is left out of view. */
@media (prefers-reduced-motion: reduce) {
[data-vb="logos-belt"] [data-vb-belt] { width: auto; justify-content: center; }
[data-vb="logos-belt"] [data-vb-belt] > ul { flex-wrap: wrap; justify-content: center; row-gap: 1rem; padding-inline: 1rem; flex-shrink: 1; }
[data-vb="logos-belt"] [data-vb-belt] > ul[aria-hidden] { display: none; }
[data-vb="logos-belt"] [data-vb-scroller] { mask-image: none; }
}
:where([data-motion="0"], [data-motion="1"]) [data-vb="logos-belt"] [data-vb-belt] { width: auto; justify-content: center; }
:where([data-motion="0"], [data-motion="1"]) [data-vb="logos-belt"] [data-vb-belt] > ul { flex-wrap: wrap; justify-content: center; row-gap: 1rem; padding-inline: 1rem; flex-shrink: 1; }
:where([data-motion="0"], [data-motion="1"]) [data-vb="logos-belt"] [data-vb-belt] > ul[aria-hidden] { display: none; }
/* The ribbon slides with the page (CSS scroll-driven animation, no script): the serif row left, the
outlined row right. Without scroll timelines it drifts slowly instead; still with motion off. */
@keyframes vb-ribbon-l { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-28%, 0, 0); } }
@keyframes vb-ribbon-r { from { transform: translate3d(-28%, 0, 0); } to { transform: translate3d(0, 0, 0); } }
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
:root:not([data-motion="0"]) [data-vb="logos-belt"] [data-vb-ribbon="l"] { animation: vb-ribbon-l linear both; animation-timeline: view(); }
:root:not([data-motion="0"]) [data-vb="logos-belt"] [data-vb-ribbon="r"] { animation: vb-ribbon-r linear both; animation-timeline: view(); }
}
@supports not (animation-timeline: view()) {
:root:not([data-motion="0"]) [data-vb="logos-belt"] [data-vb-ribbon="l"] { animation: vb-ribbon-l 60s linear infinite alternate; }
:root:not([data-motion="0"]) [data-vb="logos-belt"] [data-vb-ribbon="r"] { animation: vb-ribbon-r 60s linear infinite alternate; }
}
}
[data-vb="logos-belt"] [data-vb-ribbon="r"] { transform: translate3d(-14%, 0, 0); }
@media (prefers-reduced-motion: reduce) {
[data-vb="logos-belt"] [data-vb-ribbon] { width: 100%; transform: none; }
[data-vb="logos-belt"] [data-vb-ribbon] > ul:first-child { flex-wrap: wrap; flex-shrink: 1; min-width: 0; width: 100%; justify-content: center; white-space: normal; padding-inline: 1rem; row-gap: 0.5rem; }
[data-vb="logos-belt"] [data-vb-ribbon] > ul[aria-hidden], [data-vb="logos-belt"] [data-vb-ribbon="r"] { display: none; }
[data-vb="logos-belt"] [data-vb-ribbon] > ul:first-child span { white-space: normal; text-align: center; }
}
[data-motion="0"] [data-vb="logos-belt"] [data-vb-ribbon] { width: 100%; transform: none; }
[data-motion="0"] [data-vb="logos-belt"] [data-vb-ribbon] > ul:first-child { flex-wrap: wrap; flex-shrink: 1; min-width: 0; width: 100%; justify-content: center; white-space: normal; padding-inline: 1rem; row-gap: 0.5rem; }
[data-motion="0"] [data-vb="logos-belt"] [data-vb-ribbon] > ul[aria-hidden], [data-motion="0"] [data-vb="logos-belt"] [data-vb-ribbon="r"] { display: none; }
[data-motion="0"] [data-vb="logos-belt"] [data-vb-ribbon] > ul:first-child span { white-space: normal; text-align: center; }
`;
type Item = Content["items"][number];
const RIBBON_TYPE = "text-[clamp(2.2rem,1.2rem+4vw,5.2rem)] leading-[1.1] tracking-[-0.02em] whitespace-nowrap";
/** One run of the ribbon: names with a mark between them; only the first run of the first row is the real, editable list. */
function RibbonRun({ items, row, copy }: { items: Item[]; row: "l" | "r"; copy: boolean }) {
const type = row === "l" ? "font-serif italic font-normal" : "font-display font-semibold text-transparent [-webkit-text-stroke:1.2px_color-mix(in_oklab,var(--foreground)_55%,transparent)]";
return (
<ul aria-hidden={copy || undefined} className="flex shrink-0 items-center gap-[clamp(24px,3vw,48px)] pr-[clamp(24px,3vw,48px)]">
{items.map((item, index) => (
<li key={index} className="flex items-center gap-[clamp(24px,3vw,48px)]">
{copy || isFillIn(item.name) ? (
isFillIn(item.name) ? (
<span className={cn(RIBBON_TYPE, "font-sans text-[clamp(1.1rem,0.8rem+1.2vw,1.8rem)]")}>
<FillIn value={item.name} path={copy ? undefined : `items.${index}.name`} />
</span>
) : (
<span className={cn(RIBBON_TYPE, type)}>{item.name}</span>
)
) : (
<SlotText value={item.name} path={`items.${index}.name`} className={cn(RIBBON_TYPE, type)} />
)}
<span aria-hidden="true" className={cn("shrink-0 rounded-full", row === "l" ? "size-[0.9rem] border-2 border-spectrum-2" : "size-2.5 bg-spectrum-2")} />
</li>
))}
</ul>
);
}
function Entry({ item, index, large, copy, wrap }: { item: Item; index: number; large?: boolean; copy?: boolean; wrap?: boolean }) {
if (item.logo) {
return (
<span className="inline-flex h-9 items-center opacity-80 grayscale transition-[opacity,filter] hover:opacity-100 hover:grayscale-0 sm:h-10">
<Media image={copy ? { ...item.logo, alt: "" } : item.logo} className="h-full w-auto object-contain" />
</span>
);
}
const style = cn(wrap ? "text-balance" : "whitespace-nowrap", "font-display font-semibold tracking-[-0.02em]", large ? "text-[clamp(1.3rem,1rem+1.4vw,2.2rem)]" : "text-[1.15rem] text-muted-foreground");
// The marquee's repeat is decoration: no editor paths.
if (copy) return isFillIn(item.name) ? <FillIn value={item.name} /> : <span className={style}>{item.name}</span>;
return <SlotText value={item.name} path={`items.${index}.name`} className={style} />;
}
export type LogosBeltProps = BlockProps<Content, Variant>;
export function LogosBelt({ content, variant = "type-line", id, tone, className }: LogosBeltProps) {
return (
<Section
slug="logos-belt"
variant={variant}
id={id}
tone={tone}
spacing={false}
className={cn("border-y border-border py-[clamp(24px,3.5vw,40px)]", className)}
>
<style href="vb-logos-belt" precedence="vb">
{STYLES}
</style>
{variant === "ribbon" ? (
<div className="grid gap-[clamp(8px,1.4vw,18px)] overflow-hidden py-[clamp(20px,3vw,40px)]">
<SlotText as="p" value={content.label} path="label" className="sr-only" />
{(["l", "r"] as const).map((row) => (
<div key={row} data-vb-ribbon={row} aria-hidden={row === "r" || undefined} className="flex w-max">
{[0, 1, 2, 3].map((run) => (
<RibbonRun key={run} items={content.items} row={row} copy={row === "r" || run > 0} />
))}
</div>
))}
</div>
) : variant === "marquee" ? (
<div className="grid gap-5">
<Container>
<SlotText as="p" value={content.label} path="label" className="text-center text-small font-medium text-muted-foreground" />
</Container>
{/* data-vb-scroller: slots here travel through the visible strip by design. */}
<div data-vb-scroller="" className="overflow-hidden [mask-image:linear-gradient(90deg,transparent,black_12%,black_88%,transparent)]">
<div data-vb-belt="" className="flex w-max">
{[false, true].map((copy) => (
<ul key={String(copy)} aria-hidden={copy || undefined} className="flex shrink-0 items-center gap-14 pr-14">
{content.items.map((item, index) => (
<li key={index}>
<Entry item={item} index={index} copy={copy} />
</li>
))}
</ul>
))}
</div>
</div>
</div>
) : variant === "wall" ? (
<Container className="grid gap-6">
<SlotText as="p" value={content.label} path="label" className="text-center text-small font-medium text-muted-foreground" />
<ul className={cn("grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-3 lg:grid-cols-6", REVEAL)}>
{content.items.map((item, index) => (
<li key={index} className="grid min-h-24 place-items-center bg-background px-4 py-6 text-center">
<Entry item={item} index={index} wrap />
</li>
))}
</ul>
</Container>
) : (
<Container className={cn("grid gap-3 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-baseline lg:gap-10", REVEAL)}>
<SlotText as="p" value={content.label} path="label" className="text-small font-medium text-muted-foreground" />
<ul className="flex flex-wrap items-center gap-y-1.5">
{content.items.map((item, index) => (
<li key={index} className="flex items-center">
{index > 0 && <span aria-hidden="true" className="mx-[0.55em] size-[0.36em] min-h-1.5 min-w-1.5 rounded-full bg-spectrum" />}
<Entry item={item} index={index} large />
</li>
))}
</ul>
</Container>
)}
</Section>
);
}components/vibebox/logos-belt/schema.ts
/**
* VibeBox Motion · Logo Belt
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { sections, type z } from "@/lib/vibebox/content";
export const variants = ["type-line", "marquee", "wall", "ribbon"] as const;
export type Variant = (typeof variants)[number];
export const content = sections.logos;
export type Content = z.infer<typeof content>;Content
Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| label | Label | text | 60 chars | — |
| items | Names or logos | list | up to 12 | — |
| items[].name | Name | text | 40 chars | Can be a fill-in |
| items[].logo | Logo | image | — | — |
Variants
Type linedefault
type-line
The label, then the names set large on one wrapping line with dots between.
Marquee
marquee
A slow, endless belt of names or logos that pauses on hover.
Wall
wall
A grid of tiles, six across on desktop.
Ribbon
ribbon
Two rows of names set huge, serif italic over outlined type, sliding in opposite directions as the page scrolls.
Accessibility and motion
- Keyboard
- Nothing to focus.
- Screen readers
- A labelled list of names or logos; the marquee's repeat is hidden.
- Touch
- Logos show in grey without hover; nothing depends on it.
- Reduced motion
- The marquee and the ribbon become a centred, wrapping list; nothing scrolls.
Edge cases and limits
- Twelve 40-character names wrap the type line onto several rows.
- Logo images keep their aspect ratio at 40 px tall.
Related

Real partner or brand names on slowly turning rings around a label; a wrapped list on phones.
Logos block · Server

About Story
FreeThe business in its own words: a tall photo with a caption beside the story and a few honest details.
About block · Server

Bento Hero
FreeThe promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.
Heroes block · Server