payload-components
blockfooter-columnssource: ok · hit
Footer Columns
A brand block beside labelled columns of navigation links, closed by a copyright rule.
npx shadcn add "https://www.payload-components.xyz/r/footer-columns.json"free
Where it goes
footerlogo-cloud
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files5 · source cached 10/14/2026, 10:18:11 PM
payload-components/source/blocks/shared/safeUrls.ts
const approvedEmbedHosts = new Set([
'airtable.com',
'docs.google.com',
'form.typeform.com',
'lookerstudio.google.com',
'maps.google.com',
'player.vimeo.com',
'vimeo.com',
'www.airtable.com',
'www.google.com',
'www.youtube-nocookie.com',
'www.youtube.com',
'youtube-nocookie.com',
'youtube.com',
])
const approvedEmbedHostSuffixes = [
'.airtable.com',
'.google.com',
'.typeform.com',
'.vimeo.com',
'.youtube-nocookie.com',
'.youtube.com',
]
const embedUrlError = 'Use an approved HTTPS embed URL.'
const formActionError = 'Use a same-origin path, such as /api/newsletter.'
const sameOriginBase = 'https://payload-components.local'
const isApprovedEmbedHost = (hostname: string) => {
const normalizedHost = hostname.toLowerCase()
return (
approvedEmbedHosts.has(normalizedHost) ||
approvedEmbedHostSuffixes.some((suffix) => normalizedHost.endsWith(suffix))
)
}
export const getSafeEmbedUrl = (value: unknown) => {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
try {
const parsed = new URL(trimmed)
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
return undefined
}
if (!isApprovedEmbedHost(parsed.hostname)) {
return undefined
}
return parsed.toString()
} catch {
return undefined
}
}
export const validateEmbedUrl = (value: unknown) => getSafeEmbedUrl(value) ? true : embedUrlError
export const getSafeFormAction = (value: unknown) => {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed || !trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('\\')) {
return undefined
}
try {
const parsed = new URL(trimmed, sameOriginBase)
if (parsed.origin !== sameOriginBase) {
return undefined
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`
} catch {
return undefined
}
}
export const validateSameOriginFormAction = (value: unknown) => {
if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) {
return true
}
return getSafeFormAction(value) ? true : formActionError
}
payload-components/source/blocks/shared/footerUrls.ts
import { getSafeFormAction } from './safeUrls'
const footerHrefError = 'Enter an HTTPS URL, a mailto:/tel: link, or a same-origin path such as /pricing.'
/**
* Resolve one footer link `href` to something safe to put on an anchor.
*
* Footers are almost entirely a link surface, and React does not sanitize `href`
* — a stored `javascript:` or `data:` value would execute on click. Everything is
* rejected here except the four shapes a real footer needs: a same-origin path,
* an HTTPS URL, `mailto:`, and `tel:`. Both the admin `validate` and the render
* path call through this, so a row written straight through the Local API (which
* skips field validation) still cannot reach the DOM unchecked.
*/
export const getSafeFooterHref = (value: unknown) => {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed || /[\r\n\t]/.test(trimmed)) return undefined
const sameOriginPath = getSafeFormAction(trimmed)
if (sameOriginPath) return sameOriginPath
try {
const parsed = new URL(trimmed)
if (parsed.username || parsed.password) return undefined
if (parsed.protocol === 'https:') return parsed.toString()
if (parsed.protocol === 'mailto:' || parsed.protocol === 'tel:') {
return parsed.toString()
}
return undefined
} catch {
return undefined
}
}
export const validateFooterHref = (value: unknown) =>
getSafeFooterHref(value) ? true : footerHrefError
payload-components/source/blocks/shared/footerFields.ts
import type { Field } from 'payload'
import { validateFooterHref } from '@/blocks/shared/footerUrls'
/**
* Shared field core for the Footer component family.
*
* Every footer variant (footer-columns, footer-simple, footer-centered, …)
* spreads `footerFields` first for the shared brand and copyright line, then
* appends its own variant-specific shape — the grouped link columns, the flat
* inline nav, or the centred stack with social links. Editing the shared brand
* shape here updates every installed footer block at once, so the family never
* drifts field-by-field across a repo.
*
* `brandLabel` is required even when a `logo` upload is set: it is the accessible
* name for the home link, so an icon-only mark still announces itself to a screen
* reader instead of reading as a bare image. When no logo is uploaded it also
* renders as the visible text wordmark.
*
* `footerLinkFields` is the one-link shape reused by every variant. Its `href` is
* validated with `validateFooterHref` in the admin and re-checked at render with
* `getSafeFooterHref`, so an unexpected scheme cannot reach the DOM even if a row
* was written straight through the Local API — see `@/blocks/shared/footerUrls`.
*
* Installed once per repo at `src/blocks/shared/footerFields.ts`; re-running
* `payload-components add footer-*` never overwrites a copy you have already edited.
*/
export const footerFields: Field[] = [
{
name: 'logo',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Brand mark shown in the footer. Falls back to Brand label when empty.',
},
},
{
name: 'brandLabel',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
admin: {
description:
'Accessible name for the home link, and the visible wordmark when no logo is uploaded.',
},
},
{
name: 'copyright',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
},
]
export const footerLinkFields: Field[] = [
{
name: 'label',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
},
{
name: 'href',
type: 'text',
custom: { payloadComponents: { localization: 'global' } },
required: true,
validate: validateFooterHref,
},
]
payload-components/source/blocks/FooterColumns/config.ts
import type { Block } from 'payload'
import { footerFields, footerLinkFields } from '@/blocks/shared/footerFields'
export const FooterColumns: Block = {
slug: 'footerColumns',
// Existing apps must migrate stored data before adopting this identifier:
// https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
dbName: 'pc_foo_col',
interfaceName: 'FooterColumnsBlock',
fields: [
// Shared footer core (logo, brandLabel, copyright). Variant-specific fields
// follow; edit the shared shape in @/blocks/shared/footerFields.
...footerFields,
{
name: 'tagline',
type: 'textarea',
custom: { payloadComponents: { localization: 'localized' } },
},
{
name: 'groups',
type: 'array',
required: true,
minRows: 1,
maxRows: 4,
admin: {
description: 'Each group becomes one labelled column of links.',
initCollapsed: true,
},
fields: [
{
name: 'name',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
admin: {
description: 'Column heading, and the accessible name for that group of links.',
},
},
{
name: 'links',
type: 'array',
required: true,
minRows: 1,
maxRows: 8,
// Shared link shape — see @/blocks/shared/footerFields.
fields: footerLinkFields,
},
],
},
],
labels: {
plural: 'Footer Columns Blocks',
singular: 'Footer Columns',
},
}
payload-components/source/blocks/FooterColumns/Component.tsx
import React from 'react'
import type { FooterColumnsBlock as FooterColumnsBlockData } from '@/payload-types'
import { getSafeFooterHref } from '@/blocks/shared/footerUrls'
import { Media } from '@/components/Media'
import { cn } from '@/utilities/ui'
// Layout adapted from tailark/blocks (MIT) — re-implemented as a Payload block.
type Props = FooterColumnsBlockData & {
id?: string
className?: string
disableInnerContainer?: boolean
}
export const FooterColumnsBlock: React.FC<Props> = ({
brandLabel,
className,
copyright,
disableInnerContainer,
groups,
id,
logo,
tagline,
}) => {
return (
<section className={cn('container', className)} id={id ? `block-${id}` : undefined}>
<div className="overflow-hidden rounded-frame border border-border/70 bg-card/35 px-6 py-10 sm:px-8 lg:px-12 lg:py-14">
<div
className={cn('flex flex-col gap-12', {
'mx-auto max-w-5xl': !disableInnerContainer,
})}
>
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-4">
{logo ? (
<Media resource={logo} imgClassName="h-8 w-auto object-contain object-left" />
) : (
<span className="text-lg font-medium tracking-title text-foreground">
{brandLabel}
</span>
)}
{tagline ? (
<p className="max-w-xs text-sm leading-6 text-muted-foreground">{tagline}</p>
) : null}
</div>
{groups && groups.length > 0
? groups.map((group, index) => (
<nav
aria-label={group.name}
className="flex flex-col gap-4"
key={group.id ?? `${group.name}-${index}`}
>
<span className="text-sm font-medium text-foreground">{group.name}</span>
<ul className="flex flex-col gap-3" role="list">
{group.links?.map((link, linkIndex) => {
const href = getSafeFooterHref(link.href)
if (!href) return null
return (
<li key={link.id ?? `${link.label}-${linkIndex}`}>
<a
className="text-sm leading-6 text-muted-foreground transition-colors hover:text-foreground"
href={href}
>
{link.label}
</a>
</li>
)
})}
</ul>
</nav>
))
: null}
</div>
<p className="border-t border-border/70 pt-8 text-sm text-muted-foreground">{copyright}</p>
</div>
</div>
</section>
)
}