Edge-anchored modal panel (top/bottom/left/right), built on the @foldkit/ui Dialog submodel.

Preview
Style
Right
Sides
src/demo/views/sheet.ts237 lines
import { Match as M, Option, Schema as S } from 'effect'
import { Command, Update } from 'foldkit'
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 Sheet from '../../generated/registry/ui/sheet'

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

const SheetSide = S.Literals(['top', 'right', 'bottom', 'left'])
type SheetSide = typeof SheetSide.Type

const SheetSideLabel: Record<SheetSide, string> = {
  top: 'Top',
  right: 'Right',
  bottom: 'Bottom',
  left: 'Left',
}

const Message = defineMessageUnion({
  GotDialogMessage: { message: Sheet.Message },
  ClickedOpenDialog: {},
  ClickedOpenSidedSheet: { side: SheetSide },
})

export const sheetView = (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')], ['Right']),
          h.div(
            [h.Class('flex flex-col items-start gap-4')],
            [
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenDialog() },
                'Open',
                h,
              ),
              h.submodel({
                slotId: model.dialog.id,
                model: model.dialog,
                view: Sheet.view,
                viewInputs: Sheet.styledViewInputs(
                  {
                    side: model.sheetSide,
                    content: ({ closeButton, title, description }, h) => [
                      Sheet.header(
                        {},
                        [
                          Sheet.title(
                            { attributes: title },
                            ['Edit profile (', SheetSideLabel[model.sheetSide], ')'],
                            h,
                          ),
                          Sheet.description(
                            { attributes: description },
                            ['Make changes to your profile here. Click save when you are done.'],
                            h,
                          ),
                        ],
                        h,
                      ),
                      h.div(
                        [h.Class('grid flex-1 auto-rows-min gap-6 px-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('sheet-demo-name'),
                                ],
                                ['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('sheet-demo-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('sheet-demo-username'),
                                ],
                                ['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('sheet-demo-username'),
                                h.Attribute('defaultValue', '@peduarte'),
                              ]),
                            ],
                          ),
                        ],
                      ),
                      Sheet.footer(
                        {},
                        [
                          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'],
                          ),
                          Sheet.closeButton(
                            {
                              attributes: closeButton,
                              className: 'border border-input bg-background hover:bg-accent',
                            },
                            ['Close'],
                            h,
                          ),
                        ],
                        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')], ['Sides']),
          h.div(
            [h.Class('flex flex-wrap gap-2')],
            [
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenSidedSheet({ side: 'top' }) },
                'Top',
                h,
              ),
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenSidedSheet({ side: 'right' }) },
                'Right',
                h,
              ),
              button<AppMessage>(
                {
                  variant: 'outline',
                  onClick: Message.ClickedOpenSidedSheet({ side: 'bottom' }),
                },
                'Bottom',
                h,
              ),
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenSidedSheet({ side: 'left' }) },
                'Left',
                h,
              ),
            ],
          ),
        ],
      ),
    ],
  )

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

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

// The sheet demo shares the dialog slice's `dialog` submodel field and its
// message tags — all dialog-engine demos drive the same submodel.
const foldSheet = Update.foldChild({
  update: Sheet.update,
  read: (model: State) => Option.some(model.dialog),
  write: (model, next) => evo(model, { dialog: () => next }),
  toParentMessage: (message) => Message.GotDialogMessage({ message }),
  foldOutMessage: foldSheetOutMessage,
})

const fields = { sheetSide: SheetSide }

const stateSchema = S.Struct({ dialog: Sheet.Model, ...fields })
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: { sheetSide: 'right' },
  messages: [Message.GotDialogMessage, Message.ClickedOpenDialog, Message.ClickedOpenSidedSheet],
  handlers: (model: State) => ({
    GotDialogMessage: (payload: typeof Message.GotDialogMessage.Type): UpdateReturn =>
      foldSheet(model, payload.message),
    ClickedOpenDialog: (): UpdateReturn => {
      const { model: next, commands = [] } = Sheet.open(model.dialog)
      return {
        model: evo(model, { dialog: () => next }),
        commands: Command.mapMessages(commands, (message) => Message.GotDialogMessage({ message })),
      }
    },
    ClickedOpenSidedSheet: ({ side }: typeof Message.ClickedOpenSidedSheet.Type): UpdateReturn => {
      const sided = evo(model, { sheetSide: () => side })
      const { model: next, commands = [] } = Sheet.open(sided.dialog)
      return {
        model: evo(sided, { dialog: () => next }),
        commands: Command.mapMessages(commands, (message) => Message.GotDialogMessage({ message })),
      }
    },
  }),
  samples: [Message.ClickedOpenDialog(), Message.ClickedOpenSidedSheet({ side: 'left' })],
})

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

Source

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

registry/default/ui/sheet.ts194 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Sheet from '@/components/ui/sheet'`
 */
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. A sheet is a Dialog
// variant anchored to an edge of the viewport instead of centered.

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
export type ViewInputs = FoldkitDialog.ViewInputs

// foldkit delta: upstream keys enter/exit motion on
// data-starting-style/data-ending-style, which foldkit cannot emit — the
// equivalent declarations are inlined under data-enter/data-leave,
// and the panel emits data-side (from the `side` view input).

export type SheetSide = 'top' | 'bottom' | 'left' | 'right'

/** Upstream SheetContent string. Positioning comes from the cn-sheet-content
 *  token keyed on the emitted data-side attribute. */
export const sheetPanelClass: Readonly<Record<SheetSide, string>> = {
  top: 'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]',
  bottom: 'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]',
  left: 'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]',
  right: 'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-enter:opacity-0 data-leave:opacity-0 data-[side=bottom]:data-enter:translate-y-[2.5rem] data-[side=bottom]:data-leave:translate-y-[2.5rem] data-[side=left]:data-enter:translate-x-[-2.5rem] data-[side=left]:data-leave:translate-x-[-2.5rem] data-[side=right]:data-enter:translate-x-[2.5rem] data-[side=right]:data-leave:translate-x-[2.5rem] data-[side=top]:data-enter:translate-y-[-2.5rem] data-[side=top]:data-leave:translate-y-[-2.5rem]',
}

/** Upstream motion classes (data-starting-style/data-ending-style variants);
 *  foldkit equivalents are added to the same tokens at style resolution. */
export const sheetMotionClass =
  'data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem]'

export const sheetBackdropClass =
  'bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-enter:opacity-0 data-leave:opacity-0 fixed inset-0 z-50 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0'

export const sheetHeaderClass = 'gap-0.5 p-4 flex flex-col'

export const sheetFooterClass = 'gap-2 p-4 mt-auto flex flex-col'

export const sheetTitleClass = 'text-foreground text-base font-medium font-heading'

export const sheetDescriptionClass = 'text-muted-foreground text-sm'

/** Upstream renders close via `<Button variant="ghost" size="icon-sm">`. */
export const sheetCloseButtonClass =
  '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-3 right-3'

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

export const header = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'sheet-header'), h.Class(cn(sheetHeaderClass, config.className))],
    children,
  )

export const title = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.h2(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'sheet-title'),
      h.Class(cn(sheetTitleClass, config.className)),
    ],
    children,
  )

export const description = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.p(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'sheet-description'),
      h.Class(cn(sheetDescriptionClass, config.className)),
    ],
    children,
  )

export const footer = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'sheet-footer'), h.Class(cn(sheetFooterClass, config.className))],
    children,
  )

export const closeButton = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.button(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'sheet-close'),
      h.Class(cn(sheetCloseButtonClass, config.className)),
    ],
    children,
  )

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

export type StyledViewInputs<M> = Readonly<{
  side?: SheetSide
  content: (render: SheetContent<M>, h: HtmlBuilder<M>) => ReadonlyArray<Child>
  className?: string
  backdropClass?: string
  panelClass?: string
  /** Extra attributes merged onto the panel (e.g. data-mobile for sidebar). */
  panelAttributes?: ReadonlyArray<Attribute<M> | ChildAttribute>
}>

/** Build styled `Dialog.ViewInputs` for a sheet. Defaults to a right-side panel. */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs<M>,
  h: HtmlBuilder<M>,
): FoldkitDialog.ViewInputs => {
  const side = viewInputs.side ?? 'right'
  return {
    toView: ({ dialog, backdrop, panel, closeButton, title, description, isVisible }) =>
      h.dialog(
        [
          ...dialog,
          h.DataAttribute('slot', 'sheet'),
          h.Class(cn('bg-transparent p-0 open:block', viewInputs.className)),
        ],
        isVisible
          ? [
              h.div([
                ...backdrop,
                h.DataAttribute('slot', 'sheet-overlay'),
                h.Class(cn(sheetBackdropClass, viewInputs.backdropClass)),
              ]),
              h.div(
                [
                  ...panel,
                  h.DataAttribute('slot', 'sheet-content'),
                  h.DataAttribute('side', side),
                  h.Class(cn(sheetPanelClass[side], sheetMotionClass, viewInputs.panelClass)),
                  ...(viewInputs.panelAttributes ?? []),
                ],
                viewInputs.content({ closeButton, title, description }, h),
              ),
            ]
          : [],
      ),
  }
}