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 HakumiComponents::Affix::Component.new(offset_top: 0) do %>
    <%= render(HakumiComponents::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 hakumi--affix:change event when the state toggles.
Code
<div class="playground-affix-basic-container">
  <div style="margin-bottom: 12px;">
    <%= render HakumiComponents::Typography::Text::Component.new(id: "affix-callback-status", strong: true) { "Not affixed" } %>
  </div>
  <div class="playground-affix-spacer-sm"></div>
  <%= render HakumiComponents::Affix::Component.new(id: "affix-callback", offset_top: 0) do %>
    <%= render(HakumiComponents::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.hakumiComponent.api?.isAffixed) {
        update({ detail: { affixed: affix.hakumiComponent.api.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 target_selector.
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 HakumiComponents::Affix::Component.new(offset_top: 0, target_selector: "#affix-scroll-target") do %>
      <%= render(HakumiComponents::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 programmatically via element.hakumiComponent.api.
Code
<div class="playground-affix-container">
  <%= render HakumiComponents::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 HakumiComponents::Affix::Component.new(id: "affix-api", offset_top: 0, target_selector: "#affix-scroll") do %>
      <%= render(HakumiComponents::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.hakumiComponent.api?.isAffixed) {
        update({ detail: { affixed: affix.hakumiComponent.api.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>

Props

Prop Type Default Description
body String nil Fallback content used when no block is provided
offset_top Number nil Distance in pixels from the top of the viewport/container to trigger affix (defaults to 0 if both offsets are nil)
offset_bottom Number nil Distance in pixels from the bottom of the viewport/container to trigger affix (mutually exclusive with offset_top)
target_selector String nil CSS selector for the scroll container (defaults to window if not specified)
z_index Integer nil Z-index value applied to the affixed content
on_change String nil Stimulus action string invoked on affix state change

Slots

Prop Type Default Description
content Slot nil Content to be affixed

HTML Options

Prop Type Default Description
**html_options Keyword args {} Additional HTML attributes merged into the root element

JavaScript API

Access via element.hakumiComponent.api or element.hakumiAffix (legacy)
Prop Type Default Description
check() Method - Recalculate affix position and state
update() Method - Alias for check()
isAffixed() Method - Check if the element is currently affixed
getState() Method - Get current affix state including offsets
setOffsetTop(value) Method - Update the top offset and clear bottom offset
setOffsetBottom(value) Method - Update the bottom offset and clear top offset