Skip to content
Motion
FreeIn reviewv1.0.0

Column Footer

Links, contact details and legal pages: the brand and a line with link columns, or one compact row.

npx shadcn@latest add @vibebox/footer-columns

Controls

Variant
<FooterColumns />

About

Rebuilds the site kit's footer. The free default footer; footer-giant-wordmark (Pro) closes with the brand set huge.

Use it when

  • Every site.

Skip it when

  • The brand wants a statement ending: use footer-giant-wordmark.

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/footer-columns
  3. 3Use it:
    import { FooterColumns } from "@/components/vibebox/footer-columns";
    import { site } from "@/content/site";
    
    <FooterColumns content={site.footer} variant="full" />

Code

2 files
components/vibebox/footer-columns/index.tsx
/**
 * VibeBox Motion · Column Footer
 * 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 { BrandMark, ChannelValue, Container, plain, Section, SiteLink, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { isFillIn } from "@/lib/vibebox/content/runtime";
import { hrefFor } from "@/lib/vibebox/content/runtime";
import { cn } from "@/lib/utils";
import type { CSSProperties } from "react";
import type { Content, Variant } from "./schema";

/*
 * Links, contact details and legal pages: the brand and a line, up to four link columns and the
 * contact channels, or one compact row. The credit line (for example "Powered by VibeBox") comes
 * from content and is absent when the client removed it.
 */

const linkClass = "rounded-sm text-small text-muted-foreground transition-colors duration-(--vb-duration-micro) hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring";

/* The stage's closing wordmark. The letters rise and a light passes through the outline as the
   footer scrolls into view (CSS scroll-driven animation, `translate` so hover keeps `transform`);
   hover or press lifts and fills a letter. Still with reduced motion or motion off. */
const STAGE_STYLES = `
/* The closing scene is deep in both modes: on a dark page the inverse tone would turn it to paper,
   so it keeps the dark set, a step deeper than the page. */
:where(.dark) [data-vb="footer-columns"][data-variant="stage"].tone-inverse {
  --background: color-mix(in oklab, var(--vb-dark-background) 78%, var(--vb-light-foreground));
  --foreground: var(--vb-dark-foreground);
  --card: var(--vb-dark-card);
  --card-foreground: var(--vb-dark-foreground);
  --popover: var(--vb-dark-popover);
  --popover-foreground: var(--vb-dark-foreground);
  --muted: var(--vb-dark-muted);
  --muted-foreground: var(--vb-dark-muted-foreground);
  --subtle-foreground: var(--vb-dark-subtle-foreground);
  --accent: var(--vb-dark-accent);
  --accent-foreground: var(--vb-dark-foreground);
  --primary: var(--vb-dark-primary);
  --primary-foreground: var(--vb-dark-primary-foreground);
  --border: var(--vb-dark-border);
  --border-strong: var(--vb-dark-border-strong);
  --input: var(--vb-dark-border);
  --ring: var(--vb-dark-ring);
}
[data-vb="footer-columns"] [data-vb-wordmark] {
  font-size: min(21vw, calc(min(100vw, 1440px) / (var(--vb-chars) * 0.6)));
  line-height: 1.02; letter-spacing: -0.045em; white-space: nowrap;
  color: transparent; -webkit-text-stroke: 1px color-mix(in oklab, var(--foreground) 32%, transparent);
  background: linear-gradient(100deg, transparent 38%, color-mix(in oklab, var(--spectrum-3) 80%, transparent) 50%, transparent 62%) 130% 0 / 300% 100% no-repeat;
  -webkit-background-clip: text; background-clip: text;
}
[data-vb="footer-columns"] [data-vb-wordmark] > span { display: inline-block; transition: transform var(--vb-duration-reveal) var(--ease-expo-out), color var(--vb-duration-reveal) var(--ease-expo-out); }
[data-vb="footer-columns"] [data-vb-wordmark] > span:is(:hover, :active) { transform: translateY(-7%); color: var(--spectrum-2); }
@keyframes vb-wordmark-rise { from { translate: 0 42%; opacity: 0; } to { translate: 0 0; opacity: 1; } }
@keyframes vb-wordmark-light { from { background-position: 130% 0; } to { background-position: -30% 0; } }
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    :root:not([data-motion="0"]) [data-vb="footer-columns"] [data-vb-wordmark] { animation: vb-wordmark-light linear both; animation-timeline: view(); animation-range: entry 10% entry 100%; }
    :root:not([data-motion="0"]) [data-vb="footer-columns"] [data-vb-wordmark] > span { animation: vb-wordmark-rise linear both; animation-timeline: view(); animation-range: entry calc(var(--i) * 4%) entry calc(55% + var(--i) * 4%); }
  }
}
@media (prefers-reduced-motion: reduce) { [data-vb="footer-columns"] [data-vb-wordmark] > span { transition: none; } }
`;

/** The business name, poster-size: decoration (the brand mark above names the business). */
function Wordmark({ name }: { name: string }) {
  const letters = [...name];
  return (
    <div aria-hidden="true" className="mt-[clamp(32px,5vw,72px)] overflow-hidden select-none">
      <div data-vb-wordmark="" className="pb-[0.04em] text-center font-display font-semibold" style={{ "--vb-chars": Math.max(6, letters.length) } as CSSProperties}>
        {letters.map((letter, index) => (
          <span key={index} style={{ "--i": Math.min(index, 14) } as CSSProperties}>
            {letter === " " ? "\u00a0" : letter}
          </span>
        ))}
      </div>
    </div>
  );
}

export type FooterColumnsProps = BlockProps<Content, Variant>;

export function FooterColumns({ content, variant = "full", id, tone, className }: FooterColumnsProps) {
  const compact = variant === "compact";
  const stage = variant === "stage";
  const bottom = (
    <div className={cn("flex flex-col gap-3 text-small text-muted-foreground sm:flex-row sm:items-center sm:justify-between", !compact && "border-t border-border pt-6")}>
      {content.legal.length > 0 && (
        <ul className="flex flex-wrap gap-x-5 gap-y-1">
          {content.legal.map((item, index) => (
            <li key={index}>
              <SiteLink href={hrefFor(item.href)} className={linkClass}>
                <span data-slot={`legal.${index}.label`}>{item.label}</span>
              </SiteLink>
            </li>
          ))}
        </ul>
      )}
      {content.credit && (
        <SiteLink href={hrefFor(content.credit.href)} className={cn(linkClass, "sm:ml-auto")}>
          <span data-slot="credit.label">{content.credit.label}</span>
        </SiteLink>
      )}
      {stage && (
        <a href="#" className={cn(linkClass, "group/top inline-flex items-center gap-2", !content.credit && "sm:ml-auto")}>
          Back to top
          <span aria-hidden="true" className="grid size-7 place-items-center rounded-full border border-border transition-transform duration-(--vb-duration-reveal) ease-expo-out group-hover/top:-translate-y-1">
            <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" />
            </svg>
          </span>
        </a>
      )}
    </div>
  );
  const name = isFillIn(content.brand.name) ? "" : plain(content.brand.name);
  return (
    <Section
      as="footer"
      slug="footer-columns"
      variant={variant}
      id={id}
      tone={stage ? (tone ?? "inverse") : tone}
      spacing={false}
      className={cn("border-t border-border py-[clamp(40px,6vw,80px)]", compact && "py-8", stage && "overflow-clip border-t-0 pt-[clamp(56px,8vw,112px)] pb-0", className)}
    >
      {stage && (
        <style href="vb-footer-stage" precedence="vb">
          {STAGE_STYLES}
        </style>
      )}
      <Container className="grid grid-cols-[minmax(0,1fr)] gap-10">
        {compact ? (
          <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-center lg:gap-12">
            <SiteLink href="/" className="max-w-full justify-self-start rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring">
              <BrandMark brand={content.brand} />
            </SiteLink>
            <div className="grid gap-4">
              <SlotText as="p" value={content.blurb} path="blurb" className="max-w-[60ch] text-small text-muted-foreground" />
              {content.columns.length > 0 && (
                <nav aria-label="Footer">
                  <ul className="flex flex-wrap gap-x-6 gap-y-2">
                    {content.columns.flatMap((column, columnIndex) =>
                      column.links.map((item, index) => (
                        <li key={`${columnIndex}-${index}`}>
                          <SiteLink href={hrefFor(item.href)} className={linkClass}>
                            <span data-slot={`columns.${columnIndex}.links.${index}.label`}>{item.label}</span>
                          </SiteLink>
                        </li>
                      )),
                    )}
                  </ul>
                  {/* Column titles stay in the markup for the editor; the compact row lists the links alone. */}
                  {content.columns.map((column, columnIndex) => (
                    <span key={columnIndex} data-slot={`columns.${columnIndex}.title`} className="sr-only">
                      {column.title}
                    </span>
                  ))}
                </nav>
              )}
              {content.channels.length > 0 && (
                <dl className="flex flex-wrap gap-x-6 gap-y-2 text-small">
                  {content.channels.map((channel, index) => (
                    <div key={index} className="flex min-w-0 gap-1.5">
                      <dt data-slot={`channels.${index}.label`} className="text-muted-foreground">
                        {channel.label}
                      </dt>
                      <dd className="m-0 min-w-0">
                        <ChannelValue channel={channel} path={`channels.${index}.value`} />
                      </dd>
                    </div>
                  ))}
                </dl>
              )}
            </div>
          </div>
        ) : (
          <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,2fr)] lg:gap-16">
            <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] content-start gap-4">
              <SiteLink href="/" className="max-w-full justify-self-start rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring">
                <BrandMark brand={content.brand} size="lg" />
              </SiteLink>
              <SlotText as="p" value={content.blurb} path="blurb" className="max-w-[44ch] text-small text-pretty text-muted-foreground" />
              {content.channels.length > 0 && (
                <dl className="mt-2 grid gap-3 text-small">
                  {content.channels.map((channel, index) => (
                    <div key={index} className="grid min-w-0 gap-0.5">
                      <dt data-slot={`channels.${index}.label`} className="text-muted-foreground">
                        {channel.label}
                      </dt>
                      <dd className="m-0 min-w-0 font-medium">
                        <ChannelValue channel={channel} path={`channels.${index}.value`} />
                      </dd>
                    </div>
                  ))}
                </dl>
              )}
            </div>
            {content.columns.length > 0 && (
              <nav aria-label="Footer" className="grid grid-cols-2 gap-x-6 gap-y-8 sm:grid-cols-[repeat(auto-fit,minmax(9rem,1fr))]">
                {content.columns.map((column, columnIndex) => (
                  <div key={columnIndex} className="grid content-start gap-3">
                    <SlotText as="h2" value={column.title} path={`columns.${columnIndex}.title`} className="text-label font-semibold text-foreground" />
                    <ul className="grid gap-2">
                      {column.links.map((item, index) => (
                        <li key={index}>
                          <SiteLink href={hrefFor(item.href)} className={linkClass}>
                            <span data-slot={`columns.${columnIndex}.links.${index}.label`}>{item.label}</span>
                          </SiteLink>
                        </li>
                      ))}
                    </ul>
                  </div>
                ))}
              </nav>
            )}
          </div>
        )}
        {bottom}
      </Container>
      {stage && name && <Wordmark name={name} />}
    </Section>
  );
}
components/vibebox/footer-columns/schema.ts
/**
 * VibeBox Motion · Column Footer
 * 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 = ["full", "compact", "stage"] as const;

export type Variant = (typeof variants)[number];

export const content = sections.footer;

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
brand.nameBusiness nametext40 chars—
brand.descriptorTrade linetext32 chars—
brand.logoLogoimage——
brand.mark.shapeMark shapechoice——
brand.mark.contentMark contentchoice——
brand.mark.glyphMark glyphchoice——
brand.mark.initialMark initialtext2 chars—
brand.mark.ringMark ringflag——
blurbAbout linetext160 chars—
columnsLink columnslistup to 4—
columns[].titleColumn titletext24 chars—
columns[].linksLinkslistup to 8—
columns[].links[]Linklink40 chars—
channelsWays to reach youlistup to 4—
channels[].kindChannelchoice——
channels[].labelLabeltext24 chars—
channels[].valueValuefact120 charsYes, a fill-in until given
legalLegal linkslistup to 4—
legal[]Legal linklink40 chars—
creditCreditlink40 chars—

Variants

  • Fulldefault

    full

    Brand, line and channels left; link columns right; legal and credit beneath.

  • Compact

    compact

    Brand beside one row of links, channels and legal.

  • Stage

    stage

    The full footer on a deep surface, closing with the business name set poster-size in outline: the letters rise and a light passes through them as the footer arrives.

Accessibility and motion

Keyboard
Brand, then the links in column order, the legal links and the credit.
Screen readers
A footer with a navigation named Footer; column titles are headings; channels a description list.
Touch
Links have generous line height. The stage wordmark lifts a letter on press; it is decoration, hidden from assistive technology.
Reduced motion
Nothing moves; the stage wordmark stands complete.

Edge cases and limits

  • Four columns of eight links fit at 1440 px.
  • No credit in content: nothing is shown (branding removal).
  • A footer with the brand name as an equalizer wordmark above link columns

    A footer led by the brand as an equalizer wordmark whose letters bounce like levels on hover and focus.

    Footers block · Server

  • A footer with link columns above the brand name set huge across the page

    A footer that ends with the brand's name set edge to edge, its letters springing up into view.

    Footers 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

↑ ↓ to move↵ to openesc to close