Basic
A simple loading spinner.
Code
<%= render(Hakumi::Spin::Component.new(tip: "Loading")) %>
Size
Small, default, and large spinners.
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.
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.
Code
<%= render(Hakumi::Spin::Component.new(tip: "Still working, please wait...")) %>
Delay
Delay showing the spinner to avoid flicker.
Delayed content
The spinner will only appear if loading takes longer than
Use the button below to replay the effect.
delay (800 ms in this example).
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 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.
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
Setting
Progress: 0%
percent renders the circular progress indicator inside the spinner. Here we drive it from JavaScript to mirror upload progress.
Indeterminate queue
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>.
Analytics snapshot
Press “Simulate request” to trigger a fetch cycle controlled via
The loading overlay is applied to this card.
element.hakumiSpin.
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. |