payload-components
blockfooter-centeredsource: ok · refreshed
Footer Centered
A centred brand, tagline, and navigation above a rule carrying copyright and policy links.
npx shadcn add "https://www.payload-components.xyz/r/footer-centered.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/8/2026, 4:07:27 AM
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/FooterCentered/config.ts
import type { Block } from 'payload'
import { footerFields, footerLinkFields } from '@/blocks/shared/footerFields'
export const FooterCentered: Block = {
slug: 'footerCentered',
// Existing apps must migrate stored data before adopting this identifier:
// https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
dbName: 'pc_foo_cen',
interfaceName: 'FooterCenteredBlock',
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: 'links',
type: 'array',
required: true,
minRows: 1,
maxRows: 8,
admin: {
description: 'Primary navigation, centred under the brand.',
initCollapsed: true,
},
// Shared link shape — see @/blocks/shared/footerFields.
fields: footerLinkFields,
},
{
name: 'legalLinks',
type: 'array',
minRows: 1,
maxRows: 4,
admin: {
description: 'Policy links shown beside the copyright line — privacy, terms, cookies.',
initCollapsed: true,
},
// Shared link shape — see @/blocks/shared/footerFields.
fields: footerLinkFields,
},
],
labels: {
plural: 'Footer Centered Blocks',
singular: 'Footer Centered',
},
}
payload-components/source/blocks/FooterCentered/Component.tsx
import React from 'react'
import type { FooterCenteredBlock as FooterCenteredBlockData } 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 = FooterCenteredBlockData & {
id?: string
className?: string
disableInnerContainer?: boolean
}
export const FooterCenteredBlock: React.FC<Props> = ({
brandLabel,
className,
copyright,
disableInnerContainer,
id,
legalLinks,
links,
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-10', {
'mx-auto max-w-3xl': !disableInnerContainer,
})}
>
<div className="flex flex-col items-center gap-4 text-center">
{logo ? (
<Media resource={logo} imgClassName="h-8 w-auto object-contain" />
) : (
<span className="text-lg font-medium tracking-title text-foreground">{brandLabel}</span>
)}
{tagline ? (
<p className="max-w-md text-sm leading-6 text-muted-foreground">{tagline}</p>
) : null}
</div>
<nav aria-label={brandLabel}>
<ul className="flex flex-wrap justify-center gap-x-6 gap-y-3" role="list">
{links?.map((link, index) => {
const href = getSafeFooterHref(link.href)
if (!href) return null
return (
<li key={link.id ?? `${link.label}-${index}`}>
<a
className="text-sm leading-6 text-muted-foreground transition-colors hover:text-foreground"
href={href}
>
{link.label}
</a>
</li>
)
})}
</ul>
</nav>
<div className="flex flex-col-reverse items-center gap-4 border-t border-border/70 pt-8 sm:flex-row sm:justify-between">
<p className="text-sm text-muted-foreground">{copyright}</p>
{legalLinks && legalLinks.length > 0 ? (
<ul className="flex flex-wrap justify-center gap-x-6 gap-y-3" role="list">
{legalLinks.map((link, index) => {
const href = getSafeFooterHref(link.href)
if (!href) return null
return (
<li key={link.id ?? `${link.label}-${index}`}>
<a
className="text-sm leading-6 text-muted-foreground transition-colors hover:text-foreground"
href={href}
>
{link.label}
</a>
</li>
)
})}
</ul>
) : null}
</div>
</div>
</div>
</section>
)
}