Presentational breadcrumb landmark with list, link, page and separator builders.

Preview
Style
Basic
With Dropdown
With Link
src/demo/views/breadcrumb.ts161 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Menu as FoldkitMenu } from '@foldkit/ui'

import { Breadcrumb } from '../../generated/registry/ui/breadcrumb'
import * as menu from '../../generated/registry/ui/menu'

import { DemoMenu } from '../bundles'
import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

export const Message = defineMessageUnion({
  GotBreadcrumbMenuMessage: { message: menu.Message },
})

export const breadcrumbView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          Breadcrumb(
            {},
            [
              Breadcrumb.list(
                {},
                [
                  Breadcrumb.item({}, [Breadcrumb.link({}, ['Home'], h)], h),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.item({}, [Breadcrumb.link({}, ['Components'], h)], h),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.item({}, [Breadcrumb.page({}, ['Breadcrumb'], h)], h),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Dropdown']),
          Breadcrumb(
            {},
            [
              Breadcrumb.list(
                {},
                [
                  Breadcrumb.item({}, [Breadcrumb.link({}, ['Home'], h)], h),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.item(
                    {},
                    [
                      h.submodel({
                        slotId: model.breadcrumbMenu.id,
                        model: model.breadcrumbMenu,
                        view: DemoMenu.view,
                        viewInputs: menu.viewInputs<string>({
                          items: ['Documentation', 'Themes', 'GitHub'],
                          buttonContent: h.span(
                            [],
                            [
                              Breadcrumb.ellipsis({}, [], h),
                              h.span([h.Class('sr-only')], ['Toggle menu']),
                            ],
                          ),
                          itemToConfig: (item) => ({
                            content: h.span([], [item]),
                          }),
                        }),
                        toParentMessage: (message) => Message.GotBreadcrumbMenuMessage({ message }),
                      }),
                    ],
                    h,
                  ),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.item({}, [Breadcrumb.link({}, ['Components'], h)], h),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.item({}, [Breadcrumb.page({}, ['Breadcrumb'], h)], h),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Link']),
          Breadcrumb(
            {},
            [
              Breadcrumb.list(
                {},
                [
                  Breadcrumb.item({}, [Breadcrumb.link({}, ['Home'], h)], h),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.ellipsis({}, [], h),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.item({}, [Breadcrumb.link({}, ['Components'], h)], h),
                  Breadcrumb.separator({}, [], h),
                  Breadcrumb.item({}, [Breadcrumb.page({}, ['Breadcrumb'], h)], h),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
    ],
  )

const foldNoOp =
  <Out>(): ((out: Out) => Update.Step<State, unknown>) =>
  () =>
  (model) => ({ model })

const foldBreadcrumbMenuOutMessage = M.type<FoldkitMenu.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected: foldNoOp(),
  }),
)

const foldBreadcrumbMenu = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.breadcrumbMenu),
  write: (model, next) => evo(model, { breadcrumbMenu: () => next }),
  toParentMessage: (message) => Message.GotBreadcrumbMenuMessage({ message }),
  foldOutMessage: foldBreadcrumbMenuOutMessage,
})

const fields = { breadcrumbMenu: menu.Model }

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: { breadcrumbMenu: menu.init({ id: 'breadcrumb-dropdown' }) },
  messages: [Message.GotBreadcrumbMenuMessage],
  handlers: (model: State) => ({
    GotBreadcrumbMenuMessage: (
      payload: typeof Message.GotBreadcrumbMenuMessage.Type,
    ): UpdateReturn => foldBreadcrumbMenu(model, payload.message),
  }),
  samples: [],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/breadcrumb

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/breadcrumb.ts133 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import { cn } from '@/lib/utils'
import { icon } from '@/lib/icons'
import { ChevronRight, MoreHorizontal } from 'lucide'

type Child = Html | string

// Breadcrumb is a pure presentational landmark. `Breadcrumb` is the container
// (a `nav`); sub-builders are attached as properties: Breadcrumb.list,
// Breadcrumb.item, Breadcrumb.link, Breadcrumb.page, Breadcrumb.separator,
// Breadcrumb.ellipsis.

export const breadcrumbClass = ''

export const breadcrumbListClass = 'text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word'

export const breadcrumbItemClass = 'gap-1 inline-flex items-center'

export const breadcrumbLinkClass = 'hover:text-foreground transition-colors'

export const breadcrumbPageClass = 'text-foreground font-normal'

export const breadcrumbSeparatorClass = '[&>svg]:size-3.5'

export const breadcrumbEllipsisClass = 'size-5 [&>svg]:size-4 flex items-center justify-center'

type StyleConfig = Readonly<{ className?: string }>

const breadcrumbContainer = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.nav(
    [
      h.AriaLabel('breadcrumb'),
      h.Class(cn(breadcrumbClass, config.className)),
      h.DataAttribute('slot', 'breadcrumb'),
    ],
    children,
  )

const breadcrumbList = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.ol([h.Class(cn(breadcrumbListClass)), h.DataAttribute('slot', 'breadcrumb-list')], children)

const breadcrumbItem = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.li([h.Class(cn(breadcrumbItemClass)), h.DataAttribute('slot', 'breadcrumb-item')], children)

const breadcrumbLink = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.a(
    [
      h.Class(cn(breadcrumbLinkClass, config.className)),
      h.DataAttribute('slot', 'breadcrumb-link'),
    ],
    children,
  )

type BreadcrumbPageConfig = Readonly<{ className?: string; isCurrent?: boolean }>

const breadcrumbPage = <M>(
  config: BreadcrumbPageConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.span(
    [
      h.Role('link'),
      h.AriaDisabled(true),
      h.AriaCurrent('page'),
      h.Class(cn(breadcrumbPageClass, config.className)),
      h.DataAttribute('slot', 'breadcrumb-page'),
    ],
    children,
  )

/** Ellipsis — collapsed middle pages. */
const breadcrumbEllipsis = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child> = [],
  h: HtmlBuilder<M>,
): Html =>
  h.li(
    [
      h.Role('presentation'),
      h.AriaHidden(true),
      h.Class(cn(breadcrumbEllipsisClass, config.className)),
      h.DataAttribute('slot', 'breadcrumb-ellipsis'),
    ],
    children.length === 0
      ? [icon(h, MoreHorizontal, 'rtl:rotate-180'), h.span([h.Class('sr-only')], ['More'])]
      : children,
  )

const breadcrumbSeparator = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child> = [],
  h: HtmlBuilder<M>,
): Html =>
  h.li(
    [
      h.Role('presentation'),
      h.AriaHidden(true),
      h.Class(cn(breadcrumbSeparatorClass, config.className)),
      h.DataAttribute('slot', 'breadcrumb-separator'),
    ],
    children.length === 0 ? [icon(h, ChevronRight, 'rtl:rotate-180')] : children,
  )

/** Composable breadcrumb — `Breadcrumb` is the container, with sub-builders
 *  as properties: `Breadcrumb.list`, `Breadcrumb.item`, `Breadcrumb.link`,
 *  `Breadcrumb.page`, `Breadcrumb.separator`. */
export const Breadcrumb = Object.assign(breadcrumbContainer, {
  list: breadcrumbList,
  item: breadcrumbItem,
  link: breadcrumbLink,
  page: breadcrumbPage,
  separator: breadcrumbSeparator,
  ellipsis: breadcrumbEllipsis,
})