npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bison-lab/payload-blocks

v4.8.0

Published

Payload CMS block configs and renderers for the Bison Lab marketing blocks

Readme

@bison-lab/payload-blocks

Payload CMS block configs and renderers for the Bison Lab marketing blocks.

Full guide, with every block rendered live: https://payload.bisonlab.ai/

Install it in a Payload site and editors get the same sections @bison-lab/ui ships as React props — showcase panels, process steps, FAQ columns, testimonial masonry, NAP — as blocks they can add to a page.

pnpm add @bison-lab/payload-blocks

Peers: payload (required); react, react-dom, @bison-lab/ui and @payloadcms/richtext-lexical (needed by the renderer entries, optional if you only use the configs); @payloadcms/ui (needed by the admin entry, which every Payload site already has).

Four entry points, and why

| Import | Contents | Runs where | | --- | --- | --- | | @bison-lab/payload-blocks | Block configs, field builders, row types, resolveMedia | Node. This is what payload.config.ts imports, and it touches no React. | | @bison-lab/payload-blocks/react | Renderers, RenderBlocks, the image and link seams, the rendering types | Client ("use client"). | | @bison-lab/payload-blocks/rich-text | The richText renderer, internalDocToHrefFrom, and richTextBlockRenderer() to build one that resolves internal links | Client. Split out because it is the only thing that needs @payloadcms/richtext-lexical. | | @bison-lab/payload-blocks/admin | MinRowsArrayField, LinkField, NavItemsField, AdminWorkspace, and FooterColumnsField — the admin fields the configs reference by path | Client, inside the Payload admin. Resolved through the site's import map, never imported by hand. |

The renderers are client components because the blocks they render are: every @bison-lab/ui export is a client reference, and these blocks are interactive anyway. That is also what makes imageComponent work — a Server Component can hand a client component reference across the boundary, but not a closure.

Wiring a site

1. Add the blocks to a collection.

import {
  FaqColumnsBlock,
  HeroBlock,
  HeroCompactBlock,
  HeroSweepBlock,
  NapBlock,
  ProcessStepsBlock,
  RichTextBlock,
  ShowcasePanelsBlock,
  TestimonialMasonryBlock,
} from '@bison-lab/payload-blocks'

export const Pages: CollectionConfig = {
  slug: 'pages',
  fields: [
    { name: 'hero', type: 'blocks', blocks: [HeroBlock, HeroSweepBlock, HeroCompactBlock], maxRows: 1 },
    {
      name: 'layout',
      type: 'blocks',
      blocks: [
        RichTextBlock,
        ShowcasePanelsBlock,
        ProcessStepsBlock,
        FaqColumnsBlock,
        TestimonialMasonryBlock,
        NapBlock,
      ],
    },
  ],
}

Then payload generate:types, payload generate:importmap and payload migrate:create.

The import map step is what wires the admin fields, and it is not optional: for a field whose custom component is missing from the import map, Payload logs the miss and renders the field as nothing (an empty element stands in for the component; the stock field does not come back). Two fields need it.

Every array in this package with a minRows opens with that many empty rows and names @bison-lab/payload-blocks/admin#MinRowsArrayField as its field component, which refuses to remove a row once the count is down to the minimum (Payload gates Add on maxRows but never Remove on minRows). A site can put the same field on its own arrays:

import { MIN_ROWS_ARRAY_FIELD, emptyRows } from '@bison-lab/payload-blocks'

{
  name: 'links',
  type: 'array',
  minRows: 2,
  defaultValue: emptyRows(2),
  admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } },
  fields: [...],
}

Every link a block asks for names @bison-lab/payload-blocks/admin#LinkField on the row that holds its type, page and href, and the picker replaces those three inputs with one box: type to search the pages collection by its title field (published pages only, filtered by the server on every keystroke, each result shown with its path), or paste anything that can only be a destination — http, mailto:, tel:, or a site path starting with / — and the one offer is to link to it. The chosen state is a Page or External chip with a clear button; a page that was unpublished or deleted after it was picked shows a warning there, and Payload refuses to publish the document until it is picked again or republished, since the relationship only accepts published rows. The picker writes the same three fields the stock inputs would, so the REST API, generate:types and a site that reaches the row without the picker all see one shape: page shows for type: page, href for type: external, and required binds whichever is active. A site's own block takes the same field through linkFields() or linkField(), and can point it at another collection with pagesCollection.

lookField() and colorTokenField() read the published Theme. Options are the scales Color Settings offers page editors (Primary, Secondary, Accent, Highlight, plus every custom color) and only the included steps. Success and Destructive stay off that list. Adding Coral on Colors makes coral / coral-400 appear with no package bump. The picker is @bison-lab/payload-core/admin#LookField — run payload generate:importmap. Pass the Theme document into themeHeadFromDoc so those keys paint ([data-look] + --coral-*).

import { lookField, colorTokenField } from '@bison-lab/payload-blocks'

lookField()
colorTokenField({ name: 'fill' })

2. Own the registry.

The registry is a parameter, not an export, so the compile-time layout lock stays in your site — where the generated types are:

import type { Page } from '@/payload-types'
import {
  type BlockRegistryFor,
  FaqColumnsBlockRenderer,
  HeroBlockRenderer,
  HeroCompactBlockRenderer,
  HeroSweepBlockRenderer,
  NapBlockRenderer,
  ProcessStepsBlockRenderer,
  ShowcasePanelsBlockRenderer,
  TestimonialMasonryBlockRenderer,
} from '@bison-lab/payload-blocks/react'
import { RichTextBlockRenderer } from '@bison-lab/payload-blocks/rich-text'

type AnyBlock = NonNullable<Page['hero']>[number] | NonNullable<Page['layout']>[number]

export const blockRegistry = {
  hero: HeroBlockRenderer, // sweep, on the `hero` slug for this release
  heroSweep: HeroSweepBlockRenderer,
  heroCompact: HeroCompactBlockRenderer,
  richText: RichTextBlockRenderer,
  showcasePanels: ShowcasePanelsBlockRenderer,
  processSteps: ProcessStepsBlockRenderer,
  faqColumns: FaqColumnsBlockRenderer,
  testimonialMasonry: TestimonialMasonryBlockRenderer,
  nap: NapBlockRenderer,
} satisfies BlockRegistryFor<AnyBlock>

Add a block to the collection and satisfies fails typecheck until it has a renderer. Never widen that type to get past the error; add the entry.

3. Render the page.

<RenderBlocks
  blocks={[...page.hero, ...page.layout]}
  registry={blockRegistry}
  containerClassName={CONTAINER}
  imageComponent={NextBlockImage}
  linkComponent={NextBlockLink}
/>

containerClassName is your site's page measure. Every renderer puts it on the band wrapper and resets the library block's own max-w-* / px-* so the two cannot fight.

Every block renders inside an unstyled <div data-better-editor-id={row.id}> (BLOCK_ID_ATTRIBUTE). That is the hook payload-better-editor needs to turn a click in its preview iframe into the clicked row's fields, so a site adopting that editor has nothing to add per block. A row without an id gets no attribute. It also means each band sits one level below the render root: a test or a stylesheet that reaches the root's direct children (container.children, :scope > section) meets the wrapper, not the band.

imageComponent is how images get optimised. The package has no next dependency — next/image needs per-site configuration this package cannot supply — so a Next site writes one adapter and passes it once:

'use client'
import Image from 'next/image'
import type { BlockImageProps } from '@bison-lab/payload-blocks/react'

export function NextBlockImage({ src, alt, width, height, ...rest }: BlockImageProps) {
  if (!width || !height) return <Image src={src} alt={alt} fill {...rest} />
  return <Image src={src} alt={alt} width={width} height={height} {...rest} />
}

Uploads are served from /api/media/file/…, so widen next.config.ts images.localPatterns (or remotePatterns) to match before any CMS image renders.

linkComponent is the same seam for links. No renderer writes an <a> itself (one rich-text node excepted, below): every href a block emits — a hero call to action, a showcase panel's corner action, a NAP tel: link, a menu, a link typed into a richText section — goes through the component you pass, so a Next site's navigation stays client-side. newTab arrives as a flag and as target/rel already expanded from it, so the adapter only has to drop the flag before spreading:

'use client'
import Link from 'next/link'
import type { BlockLinkComponent } from '@bison-lab/payload-blocks/react'

export const NextBlockLink: BlockLinkComponent = ({ newTab, ...props }) => <Link {...props} />

Without one, DefaultBlockLink renders a plain anchor and sets target and rel together whenever newTab is on.

resolveLink is how a page link becomes a path. Every link a block asks for (linkFields(): a hero call to action, the testimonial link, the FAQ call to action, a menu link) is stored as type (page or external), page (a relationship into the site's pages collection, published rows only) and href. A depth: 1 page read populates page, and the renderer hands the link to resolveLink for its href. The default makes it /<slug>; a site whose routes differ passes its own, exported from a client module like the adapters above (a Server Component cannot hand a closure across the boundary):

'use client'
import { resolveLink, type ResolveLink } from '@bison-lab/payload-blocks/react'
import { pagePath } from '@/lib/routes' // your site's route helper

export const resolveSiteLink: ResolveLink = (link) =>
  link.type === 'page' && typeof link.page === 'object' && link.page?.slug
    ? pagePath(link.page.slug)
    : resolveLink(link)

A page link whose page is missing or unpublished resolves to null, and the renderer shows the label as text rather than an anchor that points nowhere (Payload hands the public read the bare id when the reader may not see the page, which is what unpublished looks like from the site). A menu drops such a link instead: an item that goes nowhere is worse than one fewer item. An external link's href is never rewritten. A row saved before links had a type carries only an href, and every reader (resolveLink, the stock fields' conditions, the picker) takes that as an external link: the site keeps rendering it and the admin shows it as an External chip. A site's migration to type: external only makes the stored row say so.

Links in a richText section go through linkComponent too, the ones Lexical auto-detects included. One kind needs more: a link an editor makes to another document has no URL, only the document, and the route is the site's to know. That is the same knowledge resolveLink carries, but Lexical hands the renderer a link node rather than one of the link rows above. The helper internalDocToHrefFrom unpacks that node into a page destination and calls the site's resolveLink, so the page path is defined once. Pass that same function to the factory (richTextBlockRenderer({ resolveLink })) and register the result in place of RichTextBlockRenderer. Do it in a 'use client' module, like the adapters above: the /rich-text entry carries the client banner, so the factory is a client reference a Server Component can pass along but cannot call, and the resolver is a closure that could not cross the boundary as a prop. A hand-written internalDocToHref still works; if both options are passed, internalDocToHref wins.

'use client'
import { richTextBlockRenderer } from '@bison-lab/payload-blocks/rich-text'
import { resolveSiteLink } from './resolve-site-link'

export const SiteRichText = richTextBlockRenderer({
  resolveLink: resolveSiteLink,
})

Without a resolver an internal link still renders through the adapter, at #, with a console error naming the option. A site whose editors link between documents should not ship that way. An unpopulated or unpublished doc follows resolveLink's null rule and the helper maps that to # — it does not invent a second unpublished policy.

An upload an editor drops into the prose goes through the same seams: an image through imageComponent (via resolveMedia, so width, height and alt travel), any other file through linkComponent at its url, labelled with the filename. A bare id — an upload that has not resolved — renders nothing.

The seam

One block floats across the join between two bands: the stats band with overlap on. It pulls itself up over the block above and down over the block below with negative margins, and each neighbour adds the same distance on its own side so the band sits across the seam without covering copy. Nothing is configured for this: RenderBlocks works out which rows float and hands every renderer two booleans, overlapAbove and overlapBelow, the same kind of derived neighbour fact as runIndex and isLast. A package renderer's band wrapper adds seamClasses(props); a renderer never reads a neighbour's row. The heroes apply that same distance inside the padding they already own.

overlapBelow adds the hero's own bottom clearance (sweep pb-36 / lg:pb-48, compact pb-28 / lg:pb-36) so a floating band can sit across the seam. Their header clearance is already taller than the pull from above, so overlapAbove adds nothing. A site that still paints the hero passes the same flag through:

export function SiteHeroRenderer(props: BlockRendererProps<HeroBlockData>) {
  return <HeroSweep overlap={props.overlapBelow} sectionClassName={props.sectionClassName} … />
}

Which rows float is RenderBlocks' floats prop, defaulting to overlapsSeam (the package's stats band with overlap on). A site with a floating block of its own composes it:

<RenderBlocks … floats={(row) => overlapsSeam(row) || row.blockType === 'bookingCard'} />

SEAM_PULL_UP / SEAM_PULL_DOWN are the floating block's negative margins and OVERLAP_ABOVE_CLEARANCE / OVERLAP_BELOW_CLEARANCE the matching padding, all from ./react, so a site block that floats uses the same distance the neighbours clear. A floating block that is first on the page keeps its top padding, and one that is last keeps its bottom padding rather than pulling the footer up.

Tailwind has to see the renderers. The padding and margins above are utilities in this package's output, not in @bison-lab/ui, so a site's stylesheet scans both packages:

@source '../../../node_modules/@bison-lab/ui/dist';
@source '../../../node_modules/@bison-lab/payload-blocks/dist';

Section settings

Every page block carries a settings group: background, spacing, anchor, and visibility. Header blocks (megaMenu, navLink) do not. The group is appended by withSectionSettings, which the package page blocks already pass through. A site with its own approved list wraps again — that replaces the background options, it does not add a second group:

import { HeroBlock, withSectionSettings } from '@bison-lab/payload-blocks'

withSectionSettings(HeroBlock, {
  backgrounds: [
    { label: 'Page', value: 'page' },
    { label: 'Primary', value: 'primary' },
    { label: 'Secondary', value: 'secondary' },
  ],
})

background stores the key, never a hex. RenderBlocks turns it into sectionClassName (the spacing class plus the class from backgroundClasses) and each package renderer puts that on its band, so the air sits inside the surface. A site-owned renderer has to do the same. page is the untouched surface and has no class unless the map gives it one. Spacing is the library's (compact, default, spacious). An anchor becomes the wrapper's id when it is URL-safe. hidden keeps the row in the editor and leaves it out of the page, including out of prevType and runIndex. A block's own fill (the hero tone, the testimonial accent) steps aside when the mapped background class is present.

The fold is @bison-lab/payload-blocks/admin#SectionSettingsField. Run payload generate:importmap. Adding the group is a schema change: run payload generate:types and payload migrate:create.

Wiring a header

Two more blocks build a header rather than a page: megaMenuBlock and LinkBlock. They go in createNavigation({ blocks })'s header.items. megaMenuBlock is a factory because the featured-link variants and the icon list are the site's — the CMS only ever offers approved values. The items field uses NAV_ITEMS_FIELD (the kit editor) instead of Payload's stock blocks UI:

import { createNavigation } from '@bison-lab/payload-core'
import { LinkBlock, megaMenuBlock } from '@bison-lab/payload-blocks'

globals: [
  ...createNavigation({
    blocks: [
      megaMenuBlock({
        variants: [
          { label: 'Lumbar', value: 'lumbar' },
          { label: 'SI joint', value: 'si' },
        ],
        icons: [{ label: 'Help', value: 'help' }],
      }),
      LinkBlock,
    ],
  }),
]

Then payload generate:types, payload generate:importmap, and payload migrate:create. Until the import map includes @bison-lab/payload-blocks/admin#NavItemsField and @bison-lab/payload-blocks/admin#AdminWorkspace, those slots render as nothing, not the stock blocks UI.

An editor opening Header sees the admin workspace: title strip (Payload status / Save draft / Publish), a 300px item rail, and a canvas with a sticky kit preview of the bar (Theme Identity lockup when one exists). Destinations go through LINK_FIELD. The Header button is the rail slot that writes header.cta (off until the editor enables it; label and destination are required to save while it is on). Column widths, read as fractions, must add up to 100%: the rule is the array's own validate, so the editor sees "The columns add up to 75%. They need to add up to 100%." as they build, and the global's publish refuses the same. A panel is narrow, standard, or wide. The mega trigger's landing page is the same type / page / href picker as every other destination.

In the site header, one call turns the fetched Header global into FloatingNavBlock props. Looks come from chrome roles (megaMenuVariantsFromLooks), not a list on Header:

'use client'
import { FloatingNavBlock, megaMenuVariantsFromLooks } from '@bison-lab/ui'
import { headerFromNavigation } from '@bison-lab/payload-blocks/react'
import { usePathname } from 'next/navigation'

export function SiteHeader({ doc }: { doc: Navigation }) {
  const pathname = usePathname()
  return (
    <FloatingNavBlock
      {...headerFromNavigation(doc, {
        looks: megaMenuVariantsFromLooks(['lumbar', 'si'], {
          lumbar: <Marker region="lumbar" />,
          si: <Marker region="si" />,
        }),
        icons: { help: <CircleHelp className="size-4" aria-hidden /> },
        isActive: (href) => pathname.startsWith(href),
        linkComponent: NextBlockLink,
      })}
      renderLink={({ href, children, ...rest }) => <Link href={href} {...rest}>{children}</Link>}
    />
  )
}

headerItemsFromBlocks is still the items half. Public headers must call headerFromNavigation so hidden bar defaults reach the bar.

Navigation settings

Bar behaviour stays on the Header schema (bar.hideOnScroll, bar.viewport, bar.defaultMaxWidth) and is hidden in the admin. The workspace canvas is the Header preview — do not leave a split live-preview iframe. The kit editor holds the edited mega row open through openItem. Featured looks stay on the chrome-role registry (BIS-85 / SPI-89) — Header has no variants array. The Spinal consume is SPI-99. After this schema change run payload generate:importmap, payload generate:types, and payload migrate:create.

MegaMenuBlockRenderer renders one panel on its own, which is what a live preview of the row wants; it is not a page block and has no place in the registry. A row with no columns, or whose columns hold no complete links, renders nothing, and headerItemsFromBlocks drops it.

Blocks

| Slug | Renders | Notes | | --- | --- | --- | | hero | HeroSweep | Alias of heroSweep for this release. Re-key existing rows to heroSweep. | | heroSweep | HeroSweep | Tones sweep, dark, and plain (light). overlapBelow adds the bottom clearance. | | heroCompact | HeroCompact | Image-led. heroCompactBlock({ extraFields }) is where a site adds its own eyebrow source. The library does not name that field. | | richText | RichText (Lexical) | From /rich-text. Links go through linkComponent; a link to another document needs richTextBlockRenderer({ resolveLink }) (or a hand-written internalDocToHref). Uploads go through imageComponent or linkComponent the same way. | | showcasePanels | ShowcasePanelsBlock | 3–6 panels, each with a required image. Opens with three. | | processSteps | ProcessStepsBlock | 3–6 ordered steps, numbered by position; advances on a timer. Opens with three. | | faqColumns | FAQColumnsBlock | Answers are plain text, not Lexical — see the config for why. | | testimonialMasonry | TestimonialMasonry | Avatars fall back to initials. | | nap | NapBlock + JsonLd | Emits schema.org LocalBusiness; phoneE164 is what the tel: link uses. | | statsBand | StatsBandBlock | 2–6 figures. Phones show two per row, tablets three, columns from lg. overlap floats it over the seam with its neighbours — see "The seam". | | resources | ResourcesBlock | One required featured card beside a list that opens empty. A card needs a heading, a date (display text) and an author, or it is dropped; no usable featured card renders nothing. The card link is a destination with no label (the heading is the link text). Category colors: register resourcesBlockRenderer({ categoryStyles }). Not in payload-core's Block library catalogue: a site lists it in createBlockLibrary({ blocks }) when it adopts the block (SPI-13 for Spinal). | | megaMenu | MegaMenuPanel | A header block, from megaMenuBlock({ variants, icons }). See "Wiring a header". | | navLink | — | A header block: a plain bar item. headerItemsFromBlocks maps it. |

A row whose upload has not resolved is dropped rather than rendered empty, and a block left with nothing to show renders nothing at all. Draft saves skip Payload validation, so live preview genuinely hands renderers incomplete rows.

Samples

Every block ships a sample row, so it can be rendered with no CMS behind it:

import { blockSamples } from '@bison-lab/payload-blocks'

blockSamples.showcasePanels // a ShowcasePanelsBlockData with three panels

blockSamples is one map keyed by blockType, typed per block (BlockSamples) and assignable to Record<BisonBlockType, BisonBlockData>. The header blocks are in it too: megaMenu renders through MegaMenuBlockRenderer, and its sample fits a config built with megaMenuBlock(megaMenuSampleOptions), the options it was written against; navLink is data only. It comes from the main entry and is React-free, so a Global's field config and a route handler can both read it. The Block library page that renders these live for an admin is createBlockLibrary in @bison-lab/payload-core; this package ships the data, not the page.

Each sample has realistic copy at the length the layout was designed for (three showcase panels, four process steps, six testimonials), and sets every field its config has at least once, so a preview shows the block's full range.

Images travel with the sample. No site media exists in a preview, so every upload in a sample is an inline SVG data: URL in the MediaDoc shape, with width and height set. resolveMedia accepts it as-is, and the data: scheme is how an adapter knows what it has: next/image marks a data: src unoptimized by itself, so the adapter above needs no special case. A site that wants the same placeholders for its own blocks can build them with sampleImage({ label, width, height, alt }).

The nap sample sets emitJsonLd: false. The block emits schema.org LocalBusiness by default, and a preview must not put a fictional clinic into a site's structured data.

Only what this package ships has a sample here. A site's own blocks supply theirs through createBlockLibrary({ blocks }). hero, heroSweep, and heroCompact each have one, so the Block library page can render them.

Conventions for contributors

  • No generated types. src/types.ts hand-writes each block's row shape to match what Payload generates from the config beside it. Every field is optional and nullable so a site's generated type is assignable to it, and no interface here has an index signature — TypeScript will not assign an interface to a type that does.
  • src/index.ts stays React-free. Payload loads a site's config outside the bundler, in migrate, generate:types and the admin server. An admin component is referenced from a config by its import-map string (MIN_ROWS_ARRAY_FIELD), never imported.
  • src/admin.tsx is the only entry that loads @payloadcms/ui. Where a stock field does most of the job it is wrapped, not re-implemented (MinRowsArrayField), so an upgrade carries every upstream behaviour along. A control Payload has no stock form of (LinkField) is written against Payload's hooks and theme variables and nothing else, so it reads in both admin themes without a stylesheet of its own. Editor-facing copy, accessible names and control text are not config fields: the @bison-lab/ui defaults stand.
  • cn() is off limits. It is a client-only export of @bison-lab/ui; use the local cx().
  • Changing a field is a schema change in every consuming site. Update src/types.ts and the field-name lock in src/__tests__/configs.test.ts in the same change, and say "run payload migrate:create" in the changeset.
  • Removing or renaming a block is a major. The changeset names the slug so a site can grep its rows before upgrading. orphanBlockTypes(rows, registry) (React-free, from the main entry) lists the types a page still holds that the registry cannot render; RenderBlocks warns once per unknown type in development and calls onUnknownBlock for every dropped row in every environment. Production stays silent besides the hook.
  • Every block ships a sample. A new block adds src/blocks/<slug>/sample.ts beside its config.ts and component.tsx, typed to its row shape, using sampleImage for every upload, and registers it in src/samples.ts. src/__tests__/samples.test.tsx fails until it does, and again if the sample leaves a config field unset. A new field on an existing block means its sample sets that field too.
  • A factory block takes only what the site must decide. megaMenuBlock takes the approved variants and icons and nothing else; a select whose options come from the site is omitted when the site passes none, so the field-name lock tests the factory with both lists given.