Back to Hologram

Events

docs-events.md

latest24.7 KB
Original Source

Events

Events in Hologram are user interactions that can trigger actions or commands. They are the primary way to make your application interactive and responsive to user input.

Event Types

Hologram supports various event types that you can bind to elements in your templates (more are coming soon):

  • $blur - triggered when an element loses focus
  • $change - triggered when the value of an input element changes
  • $click - triggered when an element is clicked
  • $click_outside - triggered when a click occurs outside an element and its descendants
  • $focus - triggered when an element receives focus
  • $key_down - triggered when a key is pressed down
  • $key_up - triggered when a key is released
  • $mouse_move - triggered when the mouse cursor moves over an element
  • $pointer_cancel - triggered when a pointer event is cancelled (e.g., when the browser decides the pointer will no longer generate events)
  • $pointer_down - triggered when a pointer (mouse, touch, pen) is pressed down on an element
  • $pointer_move - triggered when a pointer moves while over an element
  • $pointer_up - triggered when a pointer (mouse, touch, pen) is released from an element
  • $reach_bottom - triggered when a scroll container's bottom edge comes into view
  • $reach_left - triggered when a scroll container's left edge comes into view
  • $reach_right - triggered when a scroll container's right edge comes into view
  • $reach_top - triggered when a scroll container's top edge comes into view
  • $resize - triggered when an element or the browser window is resized
  • $scroll - triggered when a scrollable element or the page is scrolled
  • $select - triggered when text is selected in an input or textarea element
  • $submit - triggered when a form is submitted
  • $transition_cancel - triggered when a CSS transition is cancelled before it completes
  • $transition_end - triggered when a CSS transition has finished
  • $transition_run - triggered when a CSS transition is created
  • $transition_start - triggered when a CSS transition has started

Event Data

When an event occurs, Hologram provides event data that you can access in the action or command that was bound to handle that event. This data is available in the params argument under the :event key. The event data structure varies depending on the event type:

Change Event

The $change event data structure depends on where the event handler is placed:

  • Input-level events: include a value field with the specific element's value
  • Form-level events: include all form field values as a map, where keys are input names and values are current input values

For detailed information about $change event behavior with forms, including synthetic event mapping, usage patterns, and data types for different input types, see the "Event Handling" section in the Forms documentation.

Keyboard Events

Keyboard events ($key_down, $key_up) include:

  • alt_key - whether the Alt key was held
  • code - the physical key code (e.g. "KeyK", "Enter"), independent of keyboard layout
  • ctrl_key - whether the Ctrl key was held
  • key - the value of the key pressed (e.g. "k", "Enter", "ArrowUp"), reflecting the layout and modifiers
  • meta_key - whether the Meta key (Command on macOS, Windows key on Windows) was held
  • repeat - whether the key is being held down and auto-repeating
  • shift_key - whether the Shift key was held

To run a handler only for specific keys or key combinations, see the "Keyboard Key Filters" section below.

Mouse Events

Mouse events (like $mouse_move) include:

  • client_x - X coordinate relative to the client area (viewport)
  • client_y - Y coordinate relative to the client area (viewport)
  • movement_x - horizontal movement delta since the last mouse event
  • movement_y - vertical movement delta since the last mouse event
  • offset_x - X coordinate relative to the target element
  • offset_y - Y coordinate relative to the target element
  • page_x - X coordinate relative to the page
  • page_y - Y coordinate relative to the page
  • screen_x - X coordinate relative to the screen
  • screen_y - Y coordinate relative to the screen

Pointer Events

Pointer events (like $click, $click_outside, $pointer_cancel, $pointer_down, $pointer_move, $pointer_up) include:

  • client_x - X coordinate relative to the client area (viewport)
  • client_y - Y coordinate relative to the client area (viewport)
  • movement_x - horizontal movement delta since the last pointer event
  • movement_y - vertical movement delta since the last pointer event
  • offset_x - X coordinate relative to the target element
  • offset_y - Y coordinate relative to the target element
  • page_x - X coordinate relative to the page
  • page_y - Y coordinate relative to the page
  • pointer_type - the type of pointer that triggered the event, as an atom (:mouse, :touch, or :pen), or nil if the device type cannot be detected by the browser
  • screen_x - X coordinate relative to the screen
  • screen_y - Y coordinate relative to the screen

Click Event

A $click binding fires on a plain click, but not on a modified one - the modifier signals that the user wants the browser's own behaviour, such as opening a link in a new tab or window, so Hologram stays out of the way. A click counts as modified when any of these is held:

  • ⌥ Alt
  • ⌘ Command
  • ⌃ Ctrl
  • ⇧ Shift

Click-Outside Event

The $click_outside event fires when a click lands anywhere outside the bound element and its descendants - the gesture behind dismissible UI like dropdowns, popovers, modals, and menus. A click on the element or inside it does nothing. It is usually rendered only while the element is open, behind a conditional, so it listens for outside clicks just then.

Reach Events

The reach events fire when a scroll container is scrolled so one of its edges comes into view - the basis for patterns like infinite scroll, load-more, and pull-to-refresh. Bind them to the scrolling element, one per edge:

  • $reach_bottom - the container's bottom edge comes into view
  • $reach_left - the container's left edge comes into view
  • $reach_right - the container's right edge comes into view
  • $reach_top - the container's top edge comes into view

A reach event carries no data - it is a pure trigger. To branch on the edge in a shared handler, pass it as an action argument.

It also fires on mount when the edge is already in view - for example, a short list keeps loading until the viewport fills. To stop it firing, resolve the binding to nil from state, the same way any binding is switched off.

Append within(distance) to set how far before the edge it fires - a length such as 200px or a percentage of the container such as 50%. The default is 100% - the container's height for the top and bottom edges, its width for the left and right.

Prepending content above the viewport (for $reach_top or $reach_left) does not yet preserve the scroll position - the view stays at the top, showing the newly loaded content. This will be addressed by keyed lists, which let the browser hold the scroll position as items are inserted above.

Bind it to the scroll container - for example, a list that loads more as its bottom edge approaches:

<div $reach_bottom="load_more" style="height: 400px; overflow: auto">
  {%for item <- @items}
    <div>{item.title}</div>
  {/for}
</div>

Resize Event

The $resize event fires when the bound target's size changes - bind it to an element to track that element, or to <window> to react to the browser window being resized. It reports changes only: there is no initial dispatch when the element first renders. An element resize includes the element's box sizes, each a map with inline_size (the width, in horizontal writing mode) and block_size (the height):

  • border_box_size - the element's outer size, including padding and border
  • content_box_size - the element's inner size, excluding padding and border
  • device_pixel_content_box_size - the content box in device pixels, for pixel-perfect canvas and WebGL rendering, or nil where the browser does not support it

device_pixel_content_box_size is part of the official Resize Observer spec, but no Safari version implements it - WebKit computes device-pixel sizes only after the paint cycle, while resize observations are delivered before it. Until that gap closes, the field is nil on every Apple device, including all iOS browsers - code that reads it must handle nil.

A window resize includes no fields - the native event provides no size data.

Both bindings fire rapidly during a continuous resize - a window-edge drag, a dragged splitter pane, an animated layout - so pair them with throttle(ms) or debounce(ms) to coalesce the stream.

Scroll Event

The $scroll event fires when a scrollable element, or the page, is scrolled - bind it to an element to track that element, or to <window> / <document> to track the page. It includes:

  • scroll_left - the horizontal scroll offset of the scrolled element or page
  • scroll_top - the vertical scroll offset of the scrolled element or page

Scroll fires rapidly, so pair it with throttle(ms) or debounce(ms) to coalesce the stream.

Select Event

The $select event includes:

  • value - the selected text value

Submit Event

The $submit event includes form field values directly under the :event key. For example, if your form has fields named "email" and "password", the event data will look like:

%{email: "[email protected]", password: "secret"}

Event Binding Syntax

When binding events to elements in your templates, prefix the event name with a dollar sign ($). You can bind events using several syntax options. Note that text and shorthand syntax are only available for actions. To trigger a command, you must use the longhand syntax with the command: key.

Text Syntax (Actions Only)

The simplest way to bind an event to an action:

<button $click="my_action">Click me</button>

Expression Shorthand Syntax (Actions Only)

Use this syntax when you need to pass parameters to the action:

<button $click={:my_action, param_1: 1, param_2: 2}>Click me</button>

Expression Longhand Syntax

For complete control, including targeting specific components, triggering commands, and scheduling delayed execution:

<button $click={action: :my_action, target: "other_component", params: %{key: value}}>Click me</button>

To trigger an action with a delay (in milliseconds), use the delay: key:

<button $click={action: :my_action, delay: 1000}>Click me</button>

To trigger a command, use the command: key instead of action: (note that delays are not supported for commands):

<button $click={command: :my_command, params: %{key: value}}>Click me</button>

Disabling a Binding

An event binding that resolves to no operation is disabled: nothing is dispatched, and the browser's native behavior is left untouched (the native default runs and the event still propagates). This makes bindings conditional - compute the operation from state and return nil to switch the binding off:

<button $click={if @editable do :save end}>Save</button>

The rule covers every expression form: a nil whole value like $click={nil}, a nil shorthand name slot like $click={nil, x: 1}, and a nil longhand action: / command: key like $click={action: nil}. The value is read at event time from the current render, so a re-render can enable or disable the binding at any point.

Event Targets

By default, events are handled by the component that contains them. If the component is stateless (doesn't have a CID specified), the closest stateful component higher in the hierarchy is used. You can specify a different component for handling the event using the target parameter:

<button $click={action: :my_action, target: "other_component"}>Click me</button>

Valid targets include:

  • "page" - targets the current page
  • "layout" - targets the current layout component
  • a string representing a component ID (CID) - targets a specific component by its unique identifier within the application

Window and Document Events

Some events are not tied to any rendered element - a global keyboard shortcut, a paste anywhere on the page, a warning before the user leaves the page. Bind these with the <window> and <document> tags, which attach the event to the global window or the document rather than to an element. They render nothing and reuse the same $event syntax, keyboard key filters, and modifiers as element bindings:

<window $key_down.ctrl+k="open_palette" />

Both tags follow the same targeting rules as a regular element and accept only event bindings - any other attribute fails the build. Each tag's listener lives as long as the tag is in the template, so one behind a conditional listens only while that condition holds.

Reach for <window> for window events such as resize and scroll, and <document> for document events such as a tab becoming visible or hidden. Events that bubble, like keyboard and pointer events, reach both, so either tag works for a global shortcut.

Keyboard Key Filters

Keyboard events can be filtered to a specific key directly in the template by appending the key to the event name with a dot. The handler then fires only when that key is pressed:

<input $key_down.enter="submit_form" />

Combine modifier keys with the key using + to match keyboard shortcuts:

<input $key_down.ctrl+enter="send_message" />

Filters work on both $key_down and $key_up, and you can place several filtered bindings on one element - each fires only on its own key or combination. Keys are matched case-insensitively, so $key_down.k also matches Shift+K.

A filter matches as long as the keys it lists are pressed - any extra modifiers held at the same time do not prevent it. So $key_down.ctrl+enter still fires when Shift is also down, and $key_down.enter fires whether or not a modifier is held.

Supported Keys

  • Letters and digits - written as the character itself, e.g. k or 7
  • Modifier keys - alt, ctrl, meta (Command on macOS, Windows key on Windows), and shift, valid only when combined with a key (e.g. ctrl+k)
  • Named keys - arrow_up, arrow_down, arrow_left, arrow_right, backspace, caps_lock, delete, end, enter, escape, home, insert, page_up, page_down, space, tab, and f1 through f12

Symbol Keys

Symbol keys are written as alias words rather than the raw characters (which would clash with the template and filter syntax). Each alias is named after the key on a standard keyboard:

  • ``` → backquote
  • \backslash
  • [bracket_left
  • ]bracket_right
  • ,comma
  • =equal
  • -minus
  • .period
  • 'quote
  • ;semicolon
  • /slash

For example, to match Ctrl+/, write $key_down.ctrl+slash.

Keys that require Shift to type (such as ?, +, or &) do not have aliases - match those in your action handler via the key field.

Compile-Time Validation

Because Hologram compiles your templates, key filters are parsed and validated at compile time - not when they run in the browser. A misspelled key name like $key_down.entr fails the build with a clear error that suggests the closest valid name. In most frameworks a misspelled key is a silent runtime no-op: the handler simply never fires, and you lose time discovering why.

Dynamic Keys

Key filters are for keys known at compile time. When the key is determined at runtime - a user-configurable shortcut, or a value from state - bind the bare event and compare against the key field in your handler instead:

<input $key_down="handle_key" />

Debouncing

High-frequency events - typing in a search box, moving the pointer, scrolling a list - can fire many times per second, dispatching an action on every one. Append debounce(ms) to the event name to coalesce a burst into a single dispatch: the handler runs once, ms milliseconds after the events stop, carrying the data from the final event.

<input $change.debounce(300)="search" />

Used without a value, debounce applies a default window of 250 milliseconds - long enough to coalesce a burst of events, short enough to stay responsive once they settle:

<input $key_down.debounce="handle_key" />

Debouncing works on any event, not only keyboard events, and it combines with key filters - $key_down.enter.debounce(300) debounces only the Enter key. Each binding keeps its own timer, so several debounced bindings on one element never interfere. Like key filters, the window is validated at compile time: a non-positive or non-integer value such as $change.debounce(0) fails the build rather than misbehaving in the browser.

Debounce vs Delay

Debouncing is easy to confuse with the delay option covered in the binding syntax above, but they address different concerns and live in different places by design. debounce is an event concern: it shapes the stream of events at the edge, before any action is chosen, so it attaches to the event name as a modifier, like a key filter. delay is an action concern: it is a property of the action being dispatched, so it lives in the action specification and can even be set programmatically with put_action, where there is no event for a modifier to attach to.

In short, delay postpones an action you have already decided to dispatch, while debounce decides whether to dispatch at all - collapsing a rapid burst into one trailing call.

Throttling

Throttling caps how often a high-frequency event dispatches while it keeps firing. Append throttle(ms) to dispatch at most once per ms: the first event fires immediately, then at most one dispatch per window, with the latest event of each window dispatched on its trailing edge. It is built for continuous feedback - a live cursor readout, a drag preview, a scroll-position indicator.

<div $mouse_move.throttle(100)="update_readout"></div>

Used without a value, throttle applies a default window of 100 milliseconds - around ten updates per second, smooth enough to feel live without flooding the action pipeline:

<div $pointer_move.throttle="update_readout"></div>

Throttling works on any event and combines with key filters, and each binding keeps its own window. Like debouncing, the value is validated at compile time. A binding cannot carry both throttle and debounce - they are opposite strategies, so combining them fails the build.

Throttle vs Debounce

Both tame high-frequency events, in opposite ways. debounce waits for activity to settle and then dispatches once - reach for it when you only care about the final state, like a search query after the user stops typing. throttle dispatches at a steady capped rate while activity continues - reach for it when you want regular updates during the interaction.

In short, debounce fires once after activity stops, while throttle fires repeatedly at a capped rate during it.

Firing Once

Append once to fire a binding a single time, then stop. It suits one-shot interactions - a confirm or dismiss button, claiming an item, or lazy-loading the first time a scroll container is reached.

<button $click.once="confirm">Confirm</button>

It works on every event, including the window, document, resize, and reach events, and it combines with debounce and throttle - the single debounced or throttled dispatch fires, then the binding stops. A spent binding still handles the event as before - suppressing the native default and stopping propagation just as its modifiers do - and only stops dispatching the action.

once is a precise guarantee, scoped to the binding's target. For an element binding the target is the element instance, so re-rendering keeps a fired binding spent however many times, and it re-arms only when the element is removed and re-added as a fresh DOM node. $submit.once, for example, stops a repeated click from double-submitting but comes back armed if the form is re-created. A <window> or <document> binding has no node to re-create - it keys on the global target, which lives for the whole page, so it fires once for the page and never re-arms. To get that same page-lifetime guarantee on an element, drive it from state: have the action record that it ran, then stop rendering or disable the control.

Controlling the Native Default

Hologram prevents the browser's native default (via event.preventDefault()) on $submit - so a bound form does not reload the page. Every other event is left alone, so clicks, typing, text selection, scrolling, and the rest of the browser's native behaviour keep working. Two modifiers override this per binding: allow_default lets the native default through where Hologram would prevent it, and prevent_default prevents it where Hologram would leave it alone.

Allowing the Native Default

Append allow_default to a single binding so Hologram allows the native default for it - the browser's default then proceeds, and the action still dispatches.

<form action="https://example.com/subscribe" $submit.allow_default="track_signup">
  <button>Subscribe</button>
</form>

Use it when you want both the native behaviour and an action - for example a form that posts to an external endpoint you also want to track. It is meaningful only where Hologram actually prevents the native default (in practice $submit). It composes with key filters, stop_propagation, debounce, and throttle.

Preventing the Native Default

The mirror of allow_default is prevent_default: append it and Hologram prevents the native default for that binding. Since $submit is the only event prevented by default, prevent_default is how you do the same on any other event. The classic case is Enter-to-send on a keyboard binding, where the action should fire without the keystroke also inserting a newline or submitting the surrounding form:

<textarea $key_down.enter.prevent_default="send_message"></textarea>

It composes with key filters - so only the matched key is prevented - as well as stop_propagation, debounce, and throttle. It only affects cancelable events, so it is a no-op on ones the browser does not let you cancel - among them $change, $input, $select, $scroll, $resize, $focus, $blur, $pointer_cancel, and the $transition_* events. A binding cannot carry both allow_default and prevent_default: they are opposite intents, so combining them fails the build.

Stopping Event Propagation

DOM events bubble: an event on a nested element also fires the bindings on its ancestors. When a bound element sits inside another bound element - a delete button inside a card that opens on click - both actions dispatch. Append stop_propagation to the inner binding to stop the event at the bound element: its action still dispatches, and ancestor bindings no longer fire.

<div $click="open_card">
  <button $click.stop_propagation="delete_card">Delete</button>
</div>

Hologram never stops propagation on its own - the modifier is the per-binding opt-in. Note that document-level features rely on bubbling: $click_outside bindings and <window> / <document> bindings only see events that bubble all the way up, so an event stopped by stop_propagation never reaches them - clicking such an element inside a dismissible popover will not dismiss it. Sometimes that is exactly the point, but keep it in mind when combining the two. The modifier composes with key filters, allow_default, prevent_default, debounce, and throttle - on keyboard events, propagation stops only for keys the filter matches.

Event Flow

When an event occurs, it can trigger either an action or a command:

  • Actions - client-side operations that can update component state, trigger commands, and more. See Actions for more details.
  • Commands - server-side operations that can access server resources, trigger actions, and more. See Commands for more details.

Best Practices

When working with events in Hologram, consider these best practices:

  • Use meaningful action and command names that describe what they do
  • Keep event handlers focused on a single responsibility
  • Use the appropriate event type for the interaction you want to handle
  • Be mindful of event bubbling and use event targets appropriately
  • Use keyboard events for accessibility and keyboard navigation
  • Handle form events properly to ensure good user experience

Sponsored by

Main sponsor

Milestone sponsor

Previous

← Layouts

Next

Actions →