Composable modal dialog — Dialog.header, Dialog.title, Dialog.description, Dialog.footer, Dialog.closeButton. Built on the @foldkit/ui Dialog submodel.

Preview
Style
Basic
With Scroll

Long content scrolls inside the dialog body.

Sizes
src/demo/views/dialog.ts389 lines
import { Command, 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 { button } from '../../generated/registry/ui/button'
import * as Dialog from '../../generated/registry/ui/dialog'

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

const DialogSize = S.Literals(['sm', 'default', 'lg'])
type DialogSize = typeof DialogSize.Type

const DialogSizePanelClass: Record<DialogSize, string> = {
  sm: 'sm:max-w-sm',
  default: 'sm:max-w-lg',
  lg: 'sm:max-w-2xl',
}

const DialogSizeLabel: Record<DialogSize, string> = {
  sm: 'Small',
  default: 'Default',
  lg: 'Large',
}

const DialogContent = S.Literals(['form', 'scroll'])
type DialogContent = typeof DialogContent.Type

const Message = defineMessageUnion({
  GotDialogMessage: { message: Dialog.Message },
  ClickedOpenBasicDialog: {},
  ClickedOpenSizedDialog: { size: DialogSize },
  ClickedOpenScrollableDialog: {},
})

export const dialogView = (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']),
          h.div(
            [h.Class('flex flex-col items-start gap-4')],
            [
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenBasicDialog() },
                'Open Dialog',
                h,
              ),
              h.submodel({
                slotId: model.dialog.id,
                model: model.dialog,
                view: Dialog.view,
                viewInputs: Dialog.styledViewInputs(
                  {
                    panelClass: DialogSizePanelClass[model.dialogSize],
                    content: ({ closeButton, title, description }, h) =>
                      model.dialogContent === 'scroll'
                        ? [
                            Dialog.header(
                              {},
                              [
                                Dialog.title({ attributes: title }, ['Scrollable dialog'], h),
                                Dialog.description(
                                  { attributes: description },
                                  ['Long content scrolls inside the dialog body.'],
                                  h,
                                ),
                              ],
                              h,
                            ),
                            h.div(
                              [
                                h.Class(
                                  'max-h-[40vh] space-y-4 overflow-y-auto px-1 py-4 text-sm leading-relaxed',
                                ),
                              ],
                              [
                                h.p(
                                  [],
                                  [
                                    'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam.',
                                  ],
                                ),
                                h.p(
                                  [],
                                  [
                                    'Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.',
                                  ],
                                ),
                              ],
                            ),
                            Dialog.footer(
                              {},
                              [
                                h.button(
                                  [
                                    ...closeButton,
                                    h.Class(
                                      'inline-flex h-8 items-center justify-center rounded-lg border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground',
                                    ),
                                  ],
                                  ['Cancel'],
                                ),
                                h.button(
                                  [
                                    h.Class(
                                      'inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90',
                                    ),
                                  ],
                                  ['Done'],
                                ),
                              ],
                              h,
                            ),
                          ]
                        : [
                            Dialog.header(
                              {},
                              [
                                Dialog.title(
                                  { attributes: title },
                                  ['Edit profile (', DialogSizeLabel[model.dialogSize], ')'],
                                  h,
                                ),
                                Dialog.description(
                                  { attributes: description },
                                  [
                                    'Make changes to your profile here. Click save when you are done.',
                                  ],
                                  h,
                                ),
                              ],
                              h,
                            ),
                            h.div(
                              [h.Class('grid gap-4 py-4')],
                              [
                                h.div(
                                  [h.Class('grid gap-3')],
                                  [
                                    h.label(
                                      [
                                        h.Class(
                                          'flex items-center gap-2 text-sm leading-none font-medium',
                                        ),
                                        h.For('dialog-name-1'),
                                      ],
                                      ['Name'],
                                    ),
                                    h.input([
                                      h.Class(
                                        'flex h-8 w-full rounded-lg border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50',
                                      ),
                                      h.Id('dialog-name-1'),
                                      h.Attribute('name', 'name'),
                                      h.Attribute('defaultValue', 'Pedro Duarte'),
                                    ]),
                                  ],
                                ),
                                h.div(
                                  [h.Class('grid gap-3')],
                                  [
                                    h.label(
                                      [
                                        h.Class(
                                          'flex items-center gap-2 text-sm leading-none font-medium',
                                        ),
                                        h.For('dialog-username-1'),
                                      ],
                                      ['Username'],
                                    ),
                                    h.input([
                                      h.Class(
                                        'flex h-8 w-full rounded-lg border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50',
                                      ),
                                      h.Id('dialog-username-1'),
                                      h.Attribute('name', 'username'),
                                      h.Attribute('defaultValue', '@peduarte'),
                                    ]),
                                  ],
                                ),
                              ],
                            ),
                            Dialog.footer(
                              {},
                              [
                                h.button(
                                  [
                                    ...closeButton,
                                    h.Class(
                                      'inline-flex h-8 items-center justify-center rounded-lg border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground',
                                    ),
                                  ],
                                  ['Cancel'],
                                ),
                                h.button(
                                  [
                                    h.Class(
                                      'inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90',
                                    ),
                                  ],
                                  ['Save changes'],
                                ),
                              ],
                              h,
                            ),
                          ],
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotDialogMessage({ message }),
              }),
            ],
          ),
        ],
      ),
      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 Scroll']),
          h.div(
            [h.Class('flex flex-col items-start gap-4')],
            [
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenScrollableDialog() },
                'Open Scrollable Dialog',
                h,
              ),
              h.p(
                [h.Class('px-1 text-xs text-muted-foreground')],
                ['Long content scrolls inside the dialog body.'],
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Sizes']),
          h.div(
            [h.Class('flex flex-wrap gap-2')],
            [
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenSizedDialog({ size: 'sm' }) },
                'Small',
                h,
              ),
              button<AppMessage>(
                {
                  variant: 'outline',
                  onClick: Message.ClickedOpenSizedDialog({ size: 'default' }),
                },
                'Default',
                h,
              ),
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenSizedDialog({ size: 'lg' }) },
                'Large',
                h,
              ),
            ],
          ),
        ],
      ),
    ],
  )

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

const foldDialogOutMessage = M.type<Dialog.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Opened: foldNoOp(),
    Closed: foldNoOp(),
  }),
)

const foldDialog = Update.foldChild({
  update: Dialog.update,
  read: (model: State) => Option.some(model.dialog),
  write: (model, next) => evo(model, { dialog: () => next }),
  toParentMessage: (message) => Message.GotDialogMessage({ message }),
  foldOutMessage: foldDialogOutMessage,
})

const fields = { dialog: Dialog.Model, dialogSize: DialogSize, dialogContent: DialogContent }

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

export const slice = defineSlice({
  fields,
  init: {
    dialog: Dialog.init({ id: 'dialog-demo' }),
    dialogSize: 'default',
    dialogContent: 'form',
  },
  messages: [
    Message.GotDialogMessage,
    Message.ClickedOpenBasicDialog,
    Message.ClickedOpenSizedDialog,
    Message.ClickedOpenScrollableDialog,
  ],
  handlers: (model: State) => ({
    GotDialogMessage: (payload: typeof Message.GotDialogMessage.Type): UpdateReturn =>
      foldDialog(model, payload.message),
    ClickedOpenBasicDialog: (): UpdateReturn => {
      const formed = evo(model, { dialogContent: () => 'form' as const })
      const { model: next, commands = [] } = Dialog.open(formed.dialog)
      return {
        model: evo(formed, { dialog: () => next }),
        commands: Command.mapMessages(commands, (message) => Message.GotDialogMessage({ message })),
      }
    },
    ClickedOpenSizedDialog: ({
      size,
    }: typeof Message.ClickedOpenSizedDialog.Type): UpdateReturn => {
      const sized = evo(model, { dialogSize: () => size, dialogContent: () => 'form' as const })
      const { model: next, commands = [] } = Dialog.open(sized.dialog)
      return {
        model: evo(sized, { dialog: () => next }),
        commands: Command.mapMessages(commands, (message) => Message.GotDialogMessage({ message })),
      }
    },
    ClickedOpenScrollableDialog: (): UpdateReturn => {
      const scrolled = evo(model, {
        dialogContent: () => 'scroll' as const,
        dialogSize: () => 'default' as const,
      })
      const { model: next, commands = [] } = Dialog.open(scrolled.dialog)
      return {
        model: evo(scrolled, { dialog: () => next }),
        commands: Command.mapMessages(commands, (message) => Message.GotDialogMessage({ message })),
      }
    },
  }),
  samples: [Message.ClickedOpenBasicDialog(), Message.ClickedOpenSizedDialog({ size: 'lg' })],
})

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/dialog

Source

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

registry/default/ui/dialog.ts203 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Dialog from '@/components/ui/dialog'`
 */
import { Dialog as FoldkitDialog } from '@foldkit/ui'
import type { Attribute, ChildAttribute, Html, HtmlBuilder } from 'foldkit/html'

type Child = Html | string

import { cn } from '@/lib/utils'

// Re-export the @foldkit/ui Dialog submodel surface so a foldcn Dialog is a
// drop-in for wiring `h.submodel`.

export const Model = FoldkitDialog.Model
export type Model = typeof Model.Type

export const Message = FoldkitDialog.Message
export type Message = typeof Message.Type

export const OutMessage = FoldkitDialog.OutMessage
export type OutMessage = typeof OutMessage.Type

export const init = (config: InitConfig): Model =>
  FoldkitDialog.init({ isAnimated: true, ...config })
export const update = FoldkitDialog.update
export const open = FoldkitDialog.open
export const close = FoldkitDialog.close
export const titleId = FoldkitDialog.titleId
export const descriptionId = FoldkitDialog.descriptionId
export const view = FoldkitDialog.view

export type InitConfig = FoldkitDialog.InitConfig
export type RenderInfo = FoldkitDialog.RenderInfo

/** foldkit delta: the host <dialog> element's own chrome (upstream Root renders
 *  nothing). Backdrop/panel are fixed-position; this only neutralizes the
 *  native dialog box. */
export const dialogClass = 'bg-transparent p-0 open:flex items-center justify-center'

// The @foldkit/ui Dialog defers Animation submodel attributes onto the
// backdrop/panel elements: `data-enter` while entering, `data-leave` while
// leaving (never `data-state`). The sync script rewrites upstream's
// `data-open:`/`data-closed:` animation utilities to these windows during token
// sync; persistent `data-open:` styling passes through untouched.
export const dialogBackdropClass = 'data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50'

export const dialogPanelClass =
  'bg-popover text-popover-foreground data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 data-leave:zoom-out-95 data-enter:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none'

/** Upstream renders its close control as `<Button variant="ghost" size="icon-sm"
 *  className="cn-dialog-close">`; compose the same tokens here. */
export const dialogCloseButtonClass =
  'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg absolute top-2 right-2'

export const dialogTitleClass = 'text-base leading-none font-medium font-heading'

export const dialogDescriptionClass = 'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3'

export const dialogHeaderClass = 'gap-2 flex flex-col'

export const dialogFooterClass =
  'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'

// These abstract away element types, base classes, and attribute spreading.
// Use inside `styledViewInputs` content callbacks:
//
//   content: (render, h) => [
//     Dialog.header({}, [
//       Dialog.title({ attributes: render.title }, ['Title'], h),
//       Dialog.description({ attributes: render.description }, ['Subtitle'], h),
//     ], h),
//     Dialog.footer({}, [button(...)], h),
//   ]
//
// Every builder follows one shape: `(config, children, h)`. Attribute
// bundles handed to you by `styledViewInputs` go in `config.attributes`.

type StyleConfig<M> = Readonly<{
  className?: string
  /** Submodel-provided attributes (from the `styledViewInputs` content
   *  callback render bundle) to merge onto the element. */
  attributes?: ReadonlyArray<Attribute<M> | ChildAttribute>
}>

/** Dialog header wrapper. */
export const header = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'dialog-header'), h.Class(cn(dialogHeaderClass, config.className))],
    children,
  )

/** Dialog title — merges with the submodel's title attributes. */
export const title = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.h2(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'dialog-title'),
      h.Class(cn(dialogTitleClass, config.className)),
    ],
    children,
  )

/** Dialog description — merges with the submodel's description attributes. */
export const description = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.p(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'dialog-description'),
      h.Class(cn(dialogDescriptionClass, config.className)),
    ],
    children,
  )

/** Dialog footer wrapper. */
export const footer = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'dialog-footer'), h.Class(cn(dialogFooterClass, config.className))],
    children,
  )

/** Close button — merges with the submodel's closeButton attributes. */
export const closeButton = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.button(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'dialog-close'),
      h.Class(cn(dialogCloseButtonClass, config.className)),
    ],
    children,
  )

export type DialogContent<M> = Readonly<{
  closeButton: ReadonlyArray<Attribute<M> | ChildAttribute>
  title: ReadonlyArray<Attribute<M> | ChildAttribute>
  description: ReadonlyArray<Attribute<M> | ChildAttribute>
}>

export type StyledViewInputs<M> = Readonly<{
  /** Panel content. Receives the close-button, title and description
   *  attribute bundles to spread onto your own elements, or pass to
   *  Dialog.title / Dialog.description / Dialog.closeButton helpers via
   *  their `attributes` config field. */
  content: (render: DialogContent<M>, h: HtmlBuilder<M>) => ReadonlyArray<Child>
  className?: string
  backdropClass?: string
  panelClass?: string
}>

/** Build styled `Dialog.ViewInputs`. Pass your view's `h` so the content
 *  callback can dispatch your app's own messages (e.g. a destructive
 *  action button next to the dialog's `closeButton`). */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs<M>,
  h: HtmlBuilder<M>,
): FoldkitDialog.ViewInputs => ({
  toView: ({ dialog, backdrop, panel, closeButton, title, description, isVisible }) =>
    h.dialog(
      [
        ...dialog,
        h.DataAttribute('slot', 'dialog'),
        h.Class(cn(dialogClass, viewInputs.className)),
      ],
      isVisible
        ? [
            h.div([
              ...backdrop,
              h.DataAttribute('slot', 'dialog-overlay'),
              h.Class(cn(dialogBackdropClass, viewInputs.backdropClass)),
            ]),
            h.div(
              [
                ...panel,
                h.DataAttribute('slot', 'dialog-content'),
                h.Class(cn(dialogPanelClass, viewInputs.panelClass)),
              ],
              viewInputs.content({ closeButton, title, description }, h),
            ),
          ]
        : [],
    ),
})