Styled loading spinner (Loader2 icon in a status wrapper).

Preview
Style
Basic
In Buttons
In Badges
Badge Badge Badge Badge
In Input Group
In Empty State
No projects yet
You haven't created any projects yet. Get started by creating your first project.
Learn more
src/demo/views/spinner.ts147 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import { badge } from '../../generated/registry/ui/badge'
import { button } from '../../generated/registry/ui/button'
import { spinner } from '../../generated/registry/ui/spinner'
import { icon } from '../../generated/registry/lib/icons'
import { ArrowRight } from 'lucide'

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

export const spinnerView = (_model: Model, h: HtmlBuilder<Message>): 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 items-center gap-6')],
            [spinner<Message>({}, h), spinner<Message>({ className: 'size-6' }, 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')], ['In Buttons']),
          h.div(
            [h.Class('flex flex-wrap items-center gap-4')],
            [
              button<Message>({}, h.span([], [spinner<Message>({}, h), ' Submit']), h),
              button<Message>(
                { isDisabled: true },
                h.span([], [spinner<Message>({}, h), ' Disabled']),
                h,
              ),
              button<Message>(
                { variant: 'outline', isDisabled: true },
                h.span([], [spinner<Message>({}, h), ' Outline']),
                h,
              ),
              button<Message>(
                {
                  variant: 'outline',
                  size: 'icon',
                  isDisabled: true,
                  attributes: [h.AriaLabel('Loading')],
                },
                spinner<Message>({}, 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')], ['In Badges']),
          h.div(
            [h.Class('flex flex-wrap items-center justify-center gap-4')],
            [
              badge<Message>({}, [spinner<Message>({}, h), ' Badge'], h),
              badge<Message>({ variant: 'secondary' }, [spinner<Message>({}, h), ' Badge'], h),
              badge<Message>({ variant: 'destructive' }, [spinner<Message>({}, h), ' Badge'], h),
              badge<Message>({ variant: 'outline' }, [spinner<Message>({}, h), ' Badge'], 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')], ['In Input Group']),
          h.div(
            [h.Class('flex w-full max-w-sm flex-col gap-1.5')],
            [
              h.label(
                [h.Class('text-sm font-medium'), h.For('input-group-spinner')],
                ['Input Group'],
              ),
              h.div(
                [h.Class('flex items-center gap-2 rounded-lg border px-2.5 py-1')],
                [
                  h.input([
                    h.Id('input-group-spinner'),
                    h.Class('flex-1 bg-transparent outline-none text-sm'),
                    h.Placeholder('Search...'),
                  ]),
                  spinner<Message>({}, 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')], ['In Empty State']),
          h.div(
            [
              h.Class(
                'flex min-h-[280px] w-full flex-col items-center justify-center gap-4 rounded-lg border py-8',
              ),
            ],
            [
              spinner<Message>({}, h),
              h.div([h.Class('text-sm font-medium')], ['No projects yet']),
              h.div(
                [h.Class('text-sm text-muted-foreground text-center max-w-xs')],
                [
                  "You haven't created any projects yet. Get started by creating your first project.",
                ],
              ),
              h.div(
                [h.Class('flex gap-2')],
                [
                  button<Message>({}, 'Create project', h),
                  button<Message>({ variant: 'outline' }, 'Import project', h),
                ],
              ),
              h.a(
                [
                  h.Href('#'),
                  h.Class(
                    'text-sm text-muted-foreground underline-offset-4 hover:underline inline-flex items-center gap-1',
                  ),
                ],
                ['Learn more ', icon(h, ArrowRight, 'size-3')],
              ),
            ],
          ),
        ],
      ),
    ],
  )

export const slice = defineSlice({
  fields: {},
  init: {},
  messages: [],
  handlers: () => ({}),
})

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

Source

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

registry/default/ui/spinner.ts44 lines
import type { Attribute, ChildAttribute, Html, HtmlBuilder } from 'foldkit/html'
import type { IconNode } from 'lucide'
import { Loader2 } from 'lucide'

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

export const spinnerClass = 'size-4 animate-spin'

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

const nodeToAttributes = <M>(
  attrs: Record<string, string>,
  h: HtmlBuilder<M>,
): ReadonlyArray<Attribute<M> | ChildAttribute> =>
  Object.entries(attrs).map(([name, value]) => h.Attribute(name, value))

const renderIconNode = <M>(node: IconNode, h: HtmlBuilder<M>): ReadonlyArray<Html> =>
  node.map(([tag, attrs]) => {
    const stringAttrs: Record<string, string> = Object.fromEntries(
      Object.entries(attrs).map(([name, value]) => [name, String(value)]),
    )
    const attributes = nodeToAttributes(stringAttrs, h)
    return tag === 'path' ? h.path(attributes) : h.circle(attributes)
  })

/** Styled loading spinner — a `Loader2` lucide svg with `role="status"`. */
export const spinner = <M>(config: StyleConfig, h: HtmlBuilder<M>): Html =>
  h.svg(
    [
      h.DataAttribute('slot', 'spinner'),
      h.Role('status'),
      h.AriaLabel('Loading'),
      h.Class(cn(spinnerClass, config.className)),
      h.Xmlns('http://www.w3.org/2000/svg'),
      h.Fill('none'),
      h.ViewBox('0 0 24 24'),
      h.StrokeWidth('2'),
      h.Stroke('currentColor'),
      h.StrokeLinecap('round'),
      h.StrokeLinejoin('round'),
    ],
    renderIconNode(Loader2, h),
  )