Affix

Component

Interactive examples and API documentation

Basic
Affix an element to the viewport after it reaches the offset.
Code
<div class="playground-affix-basic-container">
  <div class="playground-affix-spacer-sm"></div>
  <%= render Hakumi::Affix::Component.new(offset_top: 0) do %>
    <%= render(Hakumi::Button::Component.new(type: :primary)) { "Pinned controls" } %>
  <% end %>
  <div class="playground-affix-content-lg">
    Scroll to see the button stick to the top of the viewport.
  </div>
</div>
Affixed state callback
Listen for <code>hakumi--affix:change</code> when the state toggles.
Code
<div class="playground-affix-basic-container">
  <div style="margin-bottom: 12px;">
    <%= render Hakumi::Typography::Text::Component.new(id: "affix-callback-status", strong: true) { "Not affixed" } %>
  </div>
  <div class="playground-affix-spacer-sm"></div>
  <%= render Hakumi::Affix::Component.new(id: "affix-callback", offset_top: 0) do %>
    <%= render(Hakumi::Button::Component.new(type: :default)) { "Watch affix state" } %>
  <% end %>
  <div class="playground-affix-content-lg">
    Scroll this view to trigger <code>hakumi--affix:change</code> events.
  </div>
</div>

<script>
  (() => {
    var wire = () => {
      var affix = document.getElementById("affix-callback")
      var status = document.getElementById("affix-callback-status")

      if (!affix || !status) return false

      var update = (event) => {
        var affixed = event?.detail?.affixed
        status.textContent = affixed ? "Affixed" : "Not affixed"
      }

      affix.addEventListener("hakumi--affix:change", update)

      if (affix.hakumiAffix?.isAffixed) {
        update({ detail: { affixed: affix.hakumiAffix.isAffixed() } })
      }

      return true
    }

    if (wire()) return

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

    document.addEventListener("turbo:load", onReady)
    window.addEventListener("load", onReady)
  })()
</script>
Scroll container
Affix within a custom scrollable container using <code>target_selector</code>.
Code
<div class="playground-affix-container">
  <div id="affix-scroll-target" class="playground-affix-scroll" style="height: 240px;">
    <div style="height: 120px; margin-bottom: 16px;"></div>
    <%= render Hakumi::Affix::Component.new(offset_top: 0, target_selector: "#affix-scroll-target") do %>
      <%= render(Hakumi::Button::Component.new(type: :primary)) { "Pinned in container" } %>
    <% end %>
    <div class="playground-affix-content">
      Scroll the container to keep the button affixed to its top edge.
    </div>
  </div>
</div>
JavaScript API
Control the affix via <code>element.hakumiAffix</code>.
Code
<div class="playground-affix-container">
  <%= render Hakumi::Typography::Text::Component.new(id: "affix-api-status", strong: true) { "Not affixed" } %>
  <div id="affix-scroll" class="playground-affix-scroll" style="margin-top: 12px;">
    <div class="playground-affix-spacer"></div>
    <%= render Hakumi::Affix::Component.new(id: "affix-api", offset_top: 0, target_selector: "#affix-scroll") do %>
      <%= render(Hakumi::Button::Component.new(type: :default)) { "API controlled" } %>
    <% end %>
    <div class="playground-affix-content">
      Scroll to toggle the affix state, or call the JavaScript API.
    </div>
  </div>
</div>

<script>
  (() => {
    var wire = () => {
      var affix = document.getElementById("affix-api")
      var status = document.getElementById("affix-api-status")

      if (!affix || !status) return false

      var update = (event) => {
        var affixed = event?.detail?.affixed
        status.textContent = affixed ? "Affixed" : "Not affixed"
      }

      affix.addEventListener("hakumi--affix:change", update)

      if (affix.hakumiAffix?.isAffixed) {
        update({ detail: { affixed: affix.hakumiAffix.isAffixed() } })
      }

      return true
    }

    if (wire()) return

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

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

Affix API

Prop Type Default Description
body String - Fallback content used when no block is provided (used by renderComponent).
offset_top Number 0 Distance from the top of the viewport/container to trigger affix.
offset_bottom Number - Distance from the bottom of the viewport/container to trigger affix (exclusive with offset_top).
target_selector String window CSS selector for the scroll container; defaults to window.
z_index Integer - Optional z-index applied to the affixed content.
on_change String - Stimulus action invoked on state change (e.g., <code>playground--affix#update</code>).
content slot Slot - Affixed content rendered inside the component.
**html_options Keyword args - Additional HTML attributes merged into the root element.

JavaScript API (element.hakumiAffix)

Prop Type Default Description
check() Function - Recalculate affix position and state.
update() Function - Alias for check().
isAffixed() Function - Return whether the element is currently affixed.
getState() Function - Return current offsets and affixed state.
setOffsetTop(value) Function - Update the top offset and remove the bottom offset.
setOffsetBottom(value) Function - Update the bottom offset and remove the top offset.