shadimage
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>
  )
}

More from payload-components

Browse all components in this library →