Spin

Component

Interactive examples and API documentation

Basic
A simple loading spinner.
Loading
Code
<%= render(Hakumi::Spin::Component.new(tip: "Loading")) %>
Size
Small, default, and large spinners.
Small
Default
Large
Code
<%= render(Hakumi::Space::Component.new(size: :large)) do %>
  <%= render(Hakumi::Spin::Component.new(size: :small, tip: "Small")) %>
  <%= render(Hakumi::Spin::Component.new(size: :default, tip: "Default")) %>
  <%= render(Hakumi::Spin::Component.new(size: :large, tip: "Large")) %>
<% end %>
Embedded mode
Wrap content to place it into a loading state.
Loading content
Card Title
Card content is loading.
Code
<%= render(Hakumi::Spin::Component.new(spinning: true, tip: "Loading content")) do %>
  <%= render(Hakumi::Card::Component.new(title: "Card Title")) do %>
    <%= render(Hakumi::Typography::Text::Component.new(type: :secondary)) { "Card content is loading." } %>
  <% end %>
<% end %>
Customized description
Provide a custom loading description.
Still working, please wait...
Code
<%= render(Hakumi::Spin::Component.new(tip: "Still working, please wait...")) %>
Delay
Delay showing the spinner to avoid flicker.
Delayed loading
Delayed content
The spinner will only appear if loading takes longer than delay (800 ms in this example).
Use the button below to replay the effect.
Status: idle
Code
<% spin_id = "spin-delay-demo" %>
<% status_id = "spin-delay-status" %>
<% trigger_id = "spin-delay-trigger" %>

<%= render(Hakumi::Space::Component.new(direction: :vertical, size: :large, block: true)) do |demo| %>
  <% demo.with_item do %>
    <%= render(Hakumi::Spin::Component.new(
      id: spin_id,
      spinning: false,
      delay: 800,
      tip: "Delayed loading"
    )) do %>
      <%= render(Hakumi::Card::Component.new(title: "Delayed content")) do %>
        <%= render(Hakumi::Typography::Paragraph::Component.new) do %>
          The spinner will only appear if loading takes longer than <code>delay</code> (800&nbsp;ms in this example).
        <% end %>
        <%= render(Hakumi::Typography::Text::Component.new(type: :secondary)) { "Use the button below to replay the effect." } %>
      <% end %>
    <% end %>
  <% end %>

  <% demo.with_item do %>
    <%= render(Hakumi::Space::Component.new(direction: :vertical, size: :small, block: true)) do |controls| %>
      <% controls.with_item do %>
        <%= render(Hakumi::Typography::Text::Component.new(id: status_id, type: :secondary)) { "Status: idle" } %>
      <% end %>
      <% controls.with_item do %>
        <%= render(Hakumi::Button::Component.new(id: trigger_id, type: :primary)) { "Simulate slow load" } %>
      <% end %>
    <% end %>
  <% end %>
<% end %>

<script>
  (() => {
    const spinElement = document.getElementById("<%= spin_id %>")
    const triggerButton = document.getElementById("<%= trigger_id %>")
    const statusLabel = document.getElementById("<%= status_id %>")

    if (!spinElement || !triggerButton) return

    let hideTimer = null

    const formatStatus = (spinning) => `Status: ${spinning ? "loading (after delay)" : "idle"}`
    const updateStatus = () => {
      const spinning = spinElement.hakumiSpin?.isSpinning?.() ?? false
      if (statusLabel) statusLabel.textContent = formatStatus(spinning)
    }

    const stopLoading = (api) => {
      if (hideTimer) {
        clearTimeout(hideTimer)
        hideTimer = null
      }
      api.hide()
      triggerButton.disabled = false
      updateStatus()
    }

    const wire = () => {
      const api = spinElement.hakumiSpin
      if (!api) return false

      triggerButton.addEventListener("click", () => {
        stopLoading(api)
        api.setTip("Delayed loading")
        triggerButton.disabled = true
        api.show()
        updateStatus()

        hideTimer = setTimeout(() => stopLoading(api), 2200)
      })

      spinElement.addEventListener("hakumi-component:hidden", updateStatus)

      updateStatus()
      return true
    }

    const waitForApi = () => {
      if (wire()) return
      requestAnimationFrame(waitForApi)
    }

    waitForApi()
  })()
</script>
Custom indicator
Use a custom spinning indicator.
Icon indicator
Ring indicator
Code
<%= render(Hakumi::Space::Component.new(size: :large)) do %>
  <%= render(Hakumi::Spin::Component.new(
    indicator: Hakumi::Icon::Component.new(name: :loading, spin: true, size: 28),
    tip: "Icon indicator"
  )) %>

  <%= render(Hakumi::Spin::Component.new(
    indicator: tag.span(class: "hakumi-spin-ring"),
    tip: "Ring indicator"
  )) %>
<% end %>
Progress
Show determinate or indeterminate progress.
Determinate upload
0%
Waiting to start
Setting percent renders the circular progress indicator inside the spinner. Here we drive it from JavaScript to mirror upload progress.
Progress: 0%
Indeterminate queue
Waiting for worker
Pass :auto when the backend cannot report a percentage. The spinner stays animated to signal that work is queued even though progress is unknown.
Code
<% determinate_id = "spin-progress-determinate" %>
<% determinate_status_id = "spin-progress-status" %>
<% start_button_id = "spin-progress-start" %>
<% reset_button_id = "spin-progress-reset" %>

<%= render(Hakumi::Space::Component.new(direction: :vertical, size: :large, block: true)) do |demo| %>
  <% demo.with_item do %>
    <%= render(Hakumi::Card::Component.new(title: "Determinate upload")) do %>
      <%= render(Hakumi::Space::Component.new(size: :large, align: :center, wrap: true)) do |space| %>
        <% space.with_item do %>
          <%= render(Hakumi::Spin::Component.new(
            id: determinate_id,
            spinning: false,
            percent: 0,
            tip: "Waiting to start"
          )) %>
        <% end %>
        <% space.with_item do %>
          <%= render(Hakumi::Typography::Paragraph::Component.new) do %>
            Setting <code>percent</code> renders the circular progress indicator inside the spinner. Here we drive it from JavaScript to mirror upload progress.
          <% end %>
          <%= render(Hakumi::Typography::Text::Component.new(id: determinate_status_id, type: :secondary)) { "Progress: 0%" } %>
          <%= render(Hakumi::Space::Component.new(size: :small)) do %>
            <%= render(Hakumi::Button::Component.new(id: start_button_id, type: :primary)) { "Start upload" } %>
            <%= render(Hakumi::Button::Component.new(id: reset_button_id, type: :default)) { "Reset" } %>
          <% end %>
        <% end %>
      <% end %>
    <% end %>
  <% end %>

  <% demo.with_item do %>
    <%= render(Hakumi::Card::Component.new(title: "Indeterminate queue")) do %>
      <%= render(Hakumi::Space::Component.new(size: :large, align: :center, wrap: true)) do |space| %>
        <% space.with_item do %>
          <%= render(Hakumi::Spin::Component.new(percent: :auto, tip: "Waiting for worker")) %>
        <% end %>
        <% space.with_item do %>
          <%= render(Hakumi::Typography::Paragraph::Component.new) do %>
            Pass <code>:auto</code> when the backend cannot report a percentage. The spinner stays animated to signal that work is queued even though progress is unknown.
          <% end %>
        <% end %>
      <% end %>
    <% end %>
  <% end %>
<% end %>

<script>
  (() => {
    const spinElement = document.getElementById("<%= determinate_id %>")
    const startButton = document.getElementById("<%= start_button_id %>")
    const resetButton = document.getElementById("<%= reset_button_id %>")
    const statusLabel = document.getElementById("<%= determinate_status_id %>")

    if (!spinElement) return

    let timer = null
    let currentPercent = 0

    const stopTimer = () => {
      if (!timer) return
      clearInterval(timer)
      timer = null
    }

    const updateStatus = () => {
      if (statusLabel) statusLabel.textContent = `Progress: ${currentPercent}%`
    }

    const wire = () => {
      const api = spinElement.hakumiSpin
      if (!api) return false

      const setProgress = (value, tip, { show } = {}) => {
        currentPercent = Math.max(0, Math.min(100, Math.round(value)))
        api.setPercent(currentPercent)
        api.setTip(tip)
        if (show) api.show()
        updateStatus()
      }

      startButton?.addEventListener("click", () => {
        if (timer) return
        setProgress(0, "Uploading chunks…", { show: true })

        timer = setInterval(() => {
          const nextValue = currentPercent + 15
          const isDone = nextValue >= 100
          setProgress(Math.min(nextValue, 100), isDone ? "Upload complete" : "Uploading chunks…")

          if (isDone) {
            stopTimer()
            setTimeout(() => api.hide(), 600)
          }
        }, 500)
      })

      resetButton?.addEventListener("click", () => {
        stopTimer()
        api.hide()
        setProgress(0, "Waiting to start")
      })

      updateStatus()
      return true
    }

    const waitForApi = () => {
      if (wire()) return
      requestAnimationFrame(waitForApi)
    }

    waitForApi()
  })()
</script>
Fullscreen
Fullscreen loading overlay for page-level loading.
Code
<%= render(Hakumi::Spin::Component.new(fullscreen: true, tip: "Loading page")) %>
JavaScript API
Control the spinner via <code>element.hakumiSpin</code>.
Waiting for API response
Analytics snapshot
Press “Simulate request” to trigger a fetch cycle controlled via element.hakumiSpin.
The loading overlay is applied to this card.
Status: idle
Code
<% status_id = "spin-api-status" %>

<%= render(Hakumi::Space::Component.new(direction: :vertical, size: :large, block: true)) do |demo| %>
  <% demo.with_item do %>
    <%= render(Hakumi::Spin::Component.new(
      id: "spin-api",
      spinning: false,
      delay: 500,
      tip: "Waiting for API response",
      style: "max-width: 460px; margin: 0 auto;"
    )) do %>
      <%= render(Hakumi::Card::Component.new(title: "Analytics snapshot")) do %>
        <%= render(Hakumi::Typography::Paragraph::Component.new) do %>
          Press “Simulate request” to trigger a fetch cycle controlled via <code>element.hakumiSpin</code>.
        <% end %>
        <%= render(Hakumi::Typography::Text::Component.new(type: :secondary)) do %>
          The loading overlay is applied to this card.
        <% end %>
      <% end %>
    <% end %>
  <% end %>

  <% demo.with_item do %>
    <%= render(Hakumi::Space::Component.new(direction: :vertical, size: :small, block: true)) do |controls| %>
      <% controls.with_item do %>
        <%= render(Hakumi::Typography::Text::Component.new(id: status_id, type: :secondary)) { "Status: idle" } %>
      <% end %>
      <% controls.with_item do %>
        <%= render(Hakumi::Space::Component.new(size: :small, wrap: true)) do %>
          <%= render(Hakumi::Button::Component.new(id: "spin-api-request", type: :primary)) { "Simulate request" } %>
          <%= render(Hakumi::Button::Component.new(id: "spin-api-toggle", type: :default)) { "Toggle" } %>
          <%= render(Hakumi::Button::Component.new(id: "spin-api-hide", type: :default)) { "Stop loading" } %>
        <% end %>
      <% end %>
    <% end %>
  <% end %>
<% end %>

<script>
  (() => {
    const spinElement = document.getElementById("spin-api")
    const requestButton = document.getElementById("spin-api-request")
    const toggleButton = document.getElementById("spin-api-toggle")
    const hideButton = document.getElementById("spin-api-hide")
    const statusLabel = document.getElementById("<%= status_id %>")

    if (!spinElement) return

    let pendingRequest = null

    const formatStatus = (spinning) => `Status: ${spinning ? "spinning" : "idle"}`
    const updateStatus = () => {
      const spinning = spinElement.hakumiSpin?.isSpinning?.() ?? false
      if (statusLabel) statusLabel.textContent = formatStatus(spinning)
    }

    const finishRequest = (api) => {
      if (pendingRequest) {
        clearTimeout(pendingRequest)
        pendingRequest = null
      }
      requestButton && (requestButton.disabled = false)
      api.hide()
      api.setTip("Waiting for API response")
      updateStatus()
    }

    const wire = () => {
      const api = spinElement.hakumiSpin
      if (!api) return false

      requestButton?.addEventListener("click", () => {
        if (pendingRequest) return
        api.setTip("Syncing dashboard data…")
        api.show()
        updateStatus()
        requestButton.disabled = true
        pendingRequest = setTimeout(() => {
          finishRequest(api)
        }, 1800)
      })

      toggleButton?.addEventListener("click", () => {
        api.toggle()
        updateStatus()
      })

      hideButton?.addEventListener("click", () => {
        finishRequest(api)
      })

      spinElement.addEventListener("hakumi-component:hidden", () => {
        if (pendingRequest) {
          clearTimeout(pendingRequest)
          pendingRequest = null
          requestButton && (requestButton.disabled = false)
        }
        updateStatus()
      })

      updateStatus()
      return true
    }

    const waitForApi = () => {
      if (wire()) return
      requestAnimationFrame(waitForApi)
    }

    waitForApi()
  })()
</script>
Programmatic
Create spinners via <code>HakumiComponents.renderComponent</code>.
Code
<%= render(Hakumi::Space::Component.new) do %>
  <%= render(Hakumi::Button::Component.new(id: "spin-programmatic-render", type: :primary)) { "Render spinner" } %>
  <%= render(Hakumi::Button::Component.new(id: "spin-programmatic-hide")) { "Hide spinner" } %>
<% end %>

<%= render(Hakumi::Container::Component.new(id: "spin-programmatic-target", padded: false, style: "min-height: 120px;")) %>

<script>
  (() => {
    let instance = null

    const wire = () => {
      if (!window.HakumiComponents?.renderComponent) return false

      const renderButton = document.getElementById("spin-programmatic-render")
      const hideButton = document.getElementById("spin-programmatic-hide")

      renderButton?.addEventListener("click", async () => {
        const result = await window.HakumiComponents.renderComponent("spin", {
          target: "#spin-programmatic-target",
          params: {
            id: "spin-programmatic",
            tip: "Loaded via renderComponent",
            spinning: true
          },
          mode: "replace"
        })
        instance = result.instance
      })

      hideButton?.addEventListener("click", () => instance?.hide?.())

      return true
    }

    if (wire()) return

    const onReady = () => {
      if (wire()) {
        document.removeEventListener("turbo:load", onReady)
        window.removeEventListener("load", onReady)
      }
    }

    document.addEventListener("turbo:load", onReady)
    window.addEventListener("load", onReady)
  })()
</script>

Spin API

Prop Type Default Description
spinning Boolean true Controls whether the spinner is visible.
size Symbol :default Spinner size (:small, :default, :large).
tip String or ViewComponent nil Custom description text.
delay Number 0 Delay (ms) before showing the spinner.
indicator String or ViewComponent nil Custom loading indicator.
fullscreen Boolean false Enable fullscreen overlay mode.
percent Number or :auto nil Show progress indicator (numeric or :auto for indeterminate).
content slot Slot - Wrapped content displayed behind the spinner.
**html_options Keyword args - Additional HTML attributes for the wrapper.

JavaScript API (element.hakumiSpin)

Prop Type Default Description
show() Function - Show the spinner.
hide() Function - Hide the spinner.
toggle() Function - Toggle spinner visibility.
isSpinning() Function - Return current spinning state.
setPercent(value) Function - Update the progress percent when using determinate progress.
setTip(value) Function - Update the description text.