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-columnsControls
<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
- 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/footer-columns - 3Use it:
import { FooterColumns } from "@/components/vibebox/footer-columns"; import { site } from "@/content/site"; <FooterColumns content={site.footer} variant="full" />
Code
2 filescomponents/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.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| brand.name | Business name | text | 40 chars | — |
| brand.descriptor | Trade line | text | 32 chars | — |
| brand.logo | Logo | image | — | — |
| brand.mark.shape | Mark shape | choice | — | — |
| brand.mark.content | Mark content | choice | — | — |
| brand.mark.glyph | Mark glyph | choice | — | — |
| brand.mark.initial | Mark initial | text | 2 chars | — |
| brand.mark.ring | Mark ring | flag | — | — |
| blurb | About line | text | 160 chars | — |
| columns | Link columns | list | up to 4 | — |
| columns[].title | Column title | text | 24 chars | — |
| columns[].links | Links | list | up to 8 | — |
| columns[].links[] | Link | link | 40 chars | — |
| channels | Ways to reach you | list | up to 4 | — |
| channels[].kind | Channel | choice | — | — |
| channels[].label | Label | text | 24 chars | — |
| channels[].value | Value | fact | 120 chars | Yes, a fill-in until given |
| legal | Legal links | list | up to 4 | — |
| legal[] | Legal link | link | 40 chars | — |
| credit | Credit | link | 40 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).
Related

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

A footer that ends with the brand's name set edge to edge, its letters springing up into view.
Footers 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