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

More from payload-components

Browse all components in this library →