Skip to content
Motion
FreeIn reviewv1.0.0

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-belt

Controls

Variant
<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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/logos-belt
  3. 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 files
components/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.

SlotLabelTypeLimitFact
labelLabeltext60 chars—
itemsNames or logoslistup to 12—
items[].nameNametext40 charsCan be a fill-in
items[].logoLogoimage——

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.
  • Brand names on two dashed rings around a label

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

    Logos block · Server

  • A tall horizon scene beside the story of a family dental clinic and three details

    The business in its own words: a tall photo with a caption beside the story and a few honest details.

    About block · Server

  • A headline about moving better beside a mosaic of a tile scene, a bloom scene and a class list

    The promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.

    Heroes block · Server

↑ ↓ to move↵ to openesc to close