shadimage
payload-components
blockfooter-simplesource: ok · hit

Footer Simple

A compact footer keeping the brand and one wrapped row of links on a line, copyright beneath.

npx shadcn add "https://www.payload-components.xyz/r/footer-simple.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, 2:20:57 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/FooterSimple/config.ts
import type { Block } from 'payload'

import { footerFields, footerLinkFields } from '@/blocks/shared/footerFields'

export const FooterSimple: Block = {
  slug: 'footerSimple',
  // Existing apps must migrate stored data before adopting this identifier:
  // https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
  dbName: 'pc_foo_sim',
  interfaceName: 'FooterSimpleBlock',
  fields: [
    // Shared footer core (logo, brandLabel, copyright). Variant-specific fields
    // follow; edit the shared shape in @/blocks/shared/footerFields.
    ...footerFields,
    {
      name: 'links',
      type: 'array',
      required: true,
      minRows: 1,
      maxRows: 8,
      admin: {
        description: 'A single flat row of links — keep it short so the row stays on one line.',
        initCollapsed: true,
      },
      // Shared link shape — see @/blocks/shared/footerFields.
      fields: footerLinkFields,
    },
  ],
  labels: {
    plural: 'Footer Simple Blocks',
    singular: 'Footer Simple',
  },
}
payload-components/source/blocks/FooterSimple/Component.tsx
import React from 'react'

import type { FooterSimpleBlock as FooterSimpleBlockData } 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 = FooterSimpleBlockData & {
  id?: string
  className?: string
  disableInnerContainer?: boolean
}

export const FooterSimpleBlock: React.FC<Props> = ({
  brandLabel,
  className,
  copyright,
  disableInnerContainer,
  id,
  links,
  logo,
}) => {
  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">
        <div
          className={cn('flex flex-col gap-8', {
            'mx-auto max-w-5xl': !disableInnerContainer,
          })}
        >
          <div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
            {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>
            )}

            <nav aria-label={brandLabel}>
              <ul className="flex flex-wrap 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>

          <p className="border-t border-border/70 pt-6 text-sm text-muted-foreground">{copyright}</p>
        </div>
      </div>
    </section>
  )
}

More from payload-components

Browse all components in this library →