Keyboard shortcuts in Phoenix LiveView — wiring j/k navigation the right way

· · 33 views
Table of Contents

The people who use RSS readers have a particular relationship with keyboards. They're working through hundreds of items a day, and clicking between entries with a mouse is tedious enough that most serious readers go keyboard-only after a few weeks. Google Reader understood this long before it was shut down in 2013 — its j/k/m/o bindings became an informal standard that every RSS client has been expected to implement since. Feedly, NewsBlur, NetNewsWire, Reeder — they all have them.

When we started building SlashFeed we never debated whether to add keyboard shortcuts. We only debated how. This post covers the implementation: two Phoenix LiveView hooks, about 80 lines of JavaScript between them, and a handful of non-obvious details that took longer to figure out than the happy path.


Why a phx-hook and not a pure JavaScript widget

The simplest approach to keyboard navigation in any web app is a global document.addEventListener("keydown", ...) that manipulates the DOM directly. For a static page this works. For a LiveView application it breaks quickly.

The problem is state. Pressing m needs to mark an entry as read in the database. Pressing o should record a read event on the server. If JavaScript updates the DOM directly without informing the server, the two fall out of sync on the next LiveView re-render: the server renders the old state, the DOM reverts, and the user is confused.

A Phoenix LiveView hook is the right abstraction here. The hook owns exactly two things: capturing keyboard input and managing the visual selection ring. State changes go to the server via pushEvent, which delivers events to handle_event/3 identically to a phx-click or form submission. LiveView re-renders on the server side, patches the DOM, and the hook's updated() callback reapplies the selection ring. The client and server stay in sync because the client never mutates application state unilaterally.

We ended up with two hooks for two different views:

  • KeyboardNav — the main entry list, with the full vim-style shortcut set
  • CarouselKeyNav — the reader view, where ←/→ navigate between entries in a horizontal carousel

Both are passed to the LiveSocket constructor alongside the project's existing colocated hooks:

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: {
    ...colocatedHooks,
    KeyboardNav,
    CarouselKeyNav,
  },
});

The KeyboardNav hook

Here is the full hook. We will walk through each section below.

const KeyboardNav = {
  mounted() {
    this.selectedIndex = -1;
    this.handleKeyDown = (e) => {
      // Don't capture when typing in inputs
      if (
        e.target.tagName === "INPUT" ||
        e.target.tagName === "TEXTAREA" ||
        e.target.tagName === "SELECT"
      )
        return;

      const entries = this.el.querySelectorAll('[id^="entry-"]');
      if (!entries.length) return;

      switch (e.key) {
        case "j":
        case "ArrowDown":
          e.preventDefault();
          this.selectedIndex = Math.min(this.selectedIndex + 1, entries.length - 1);
          this.updateSelection(entries);
          break;
        case "k":
        case "ArrowUp":
          e.preventDefault();
          this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
          this.updateSelection(entries);
          break;
        case "m":
          if (this.selectedIndex >= 0 && this.selectedIndex < entries.length) {
            const entryId = entries[this.selectedIndex].id.replace("entry-", "");
            this.pushEvent("toggle_read", { "entry-id": entryId });
          }
          break;
        case "o":
        case "Enter":
          if (this.selectedIndex >= 0 && this.selectedIndex < entries.length) {
            const link = entries[this.selectedIndex].querySelector('a[target="_blank"]');
            if (link) {
              const entryId = entries[this.selectedIndex].id.replace("entry-", "");
              this.pushEvent("mark_read", { entry_id: entryId });
              window.open(link.href, "_blank");
            }
          }
          break;
        case "r":
          this.pushEvent("refresh", {});
          break;
        case "Escape":
          this.selectedIndex = -1;
          this.updateSelection(entries);
          break;
      }
    };
    document.addEventListener("keydown", this.handleKeyDown);
  },

  updated() {
    const entries = this.el.querySelectorAll('[id^="entry-"]');
    this.updateSelection(entries);
  },

  destroyed() {
    document.removeEventListener("keydown", this.handleKeyDown);
  },

  updateSelection(entries) {
    entries.forEach((el, i) => {
      if (i === this.selectedIndex) {
        el.classList.add("ring-2", "ring-primary");
        el.scrollIntoView({ block: "nearest", behavior: "smooth" });
      } else {
        el.classList.remove("ring-2", "ring-primary");
      }
    });
  },
};

The input guard

The very first thing in the handler checks e.target.tagName. If the user is typing in an <input>, <textarea>, or <select>, we return immediately and let the browser process the keystroke normally. Without this, typing the letter j into the search box would simultaneously advance the selected entry in the list below. It is the kind of bug users hit constantly and cannot ignore.

The guard only checks the tag name, not contenteditable. SlashFeed has no editable regions in the entry list view, so the simpler form is sufficient. If your application adds a contenteditable element alongside the list, extend the guard accordingly.

Querying entries with [id^="entry-"]

const entries = this.el.querySelectorAll('[id^="entry-"]');
if (!entries.length) return;

The ^= CSS attribute selector matches elements whose id starts with a given string. The query is scoped to this.el — the DOM node the hook is mounted on — so it never accidentally selects anything outside the list.

This pattern works naturally with LiveView streams. The template mounts the hook on the list container and streams entries inside it:

<div id="entry-list" phx-hook="KeyboardNav">
  <%= for {id, entry} <- @streams.entries do %>
    <div id={id} ...>
      ...
    </div>
  <% end %>
</div>

LiveView streams assign each element a stable, predictable DOM ID derived from the stream name and the item's database ID. The selector targets that prefix directly. As entries are appended, prepended, or removed by stream operations, the next call to querySelectorAll in the handler always returns the current live set — no manual bookkeeping required.

updateSelection: rings and scrolling

updateSelection(entries) {
  entries.forEach((el, i) => {
    if (i === this.selectedIndex) {
      el.classList.add("ring-2", "ring-primary");
      el.scrollIntoView({ block: "nearest", behavior: "smooth" });
    } else {
      el.classList.remove("ring-2", "ring-primary");
    }
  });
},

ring-2 and ring-primary are Tailwind utility classes that add a two-pixel inset focus ring in the theme's primary colour. We iterate over all entries on every selection change, adding the ring to the selected element and removing it from all others. This is slightly more work than touching only the changed entries, but the entry list is short enough that the difference is imperceptible, and the code is easier to reason about.

scrollIntoView({ block: "nearest", behavior: "smooth" }) scrolls the viewport only as far as necessary to reveal the newly selected entry. If it is already fully visible, nothing happens.


The updated() lifecycle method

This is the detail that trips up everyone building keyboard navigation in LiveView for the first time.

After LiveView patches the DOM — a new batch of entries arrives via PubSub, or a previously-selected entry gets marked as read and its component re-renders — the browser's DOM is mutated. CSS classes that were programmatically added by the hook are not preserved through a LiveView diff. The server never knew about ring-2. It was not in the original server-rendered HTML. The diff engine has no reason to preserve it.

The result without updated(): press j to select entry five, wait for a new entry to arrive at the top of the list, LiveView patches the stream, and the ring on entry five quietly disappears. The selectedIndex is still 4 — JavaScript state is completely decoupled from the DOM — but the visual indication is gone. The user's cursor is now invisible.

updated() fires after every DOM patch on the hook's root element. We use it to reapply the selection ring:

updated() {
  // Re-apply selection after DOM update
  const entries = this.el.querySelectorAll('[id^="entry-"]');
  this.updateSelection(entries);
},

Two lines. The ring reappears at the correct position, silently, after every server-side DOM update. Without them the feature technically works but feels broken within seconds of real use.


The destroyed() cleanup

When the hook's element is removed from the DOM — the user navigates away from /entries — LiveView calls destroyed(). Any listeners added in mounted() must be removed here. Failing to clean up leaks memory and, more visibly, causes key presses to keep firing pushEvent calls to a LiveView process that may no longer exist or may have been replaced by a different view.

The critical implementation detail is the function reference. removeEventListener requires the exact same object reference that was originally passed to addEventListener. An inline arrow function cannot be removed because it is a fresh object every time it is evaluated. That is why we assign the handler to this.handleKeyDown before attaching it:

this.handleKeyDown = (e) => { /* handler body */ };
document.addEventListener("keydown", this.handleKeyDown);
destroyed() {
  document.removeEventListener("keydown", this.handleKeyDown);
},

this.handleKeyDown lives on the hook instance for the hook's full lifetime. destroyed() retrieves the same reference and passes it to removeEventListener. Clean teardown, no leaks, no ghost handlers.


Pushing events to the server

this.pushEvent sends a named event through the LiveView WebSocket channel. The server receives it in handle_event/3 identically to any other LiveView event — phx-click, phx-submit, or otherwise:

def handle_event("toggle_read", %{"entry-id" => entry_id}, socket) do
  entry = Feeds.get_entry!(entry_id)
  user = socket.assigns.current_scope.user

  case Feeds.mark_as_read(user, entry) do
    {:ok, _} ->
      {:noreply, stream_insert(socket, :entries, %{entry | read: true})}

    {:error, _} ->
      {:noreply, socket}
  end
end

def handle_event("mark_read", %{"entry_id" => entry_id}, socket) do
  entry = Feeds.get_entry!(entry_id)
  user = socket.assigns.current_scope.user
  Feeds.mark_as_read(user, entry)
  {:noreply, socket}
end

def handle_event("refresh", _params, socket) do
  send(self(), :refresh_feeds)
  {:noreply, socket}
end

The hook doesn't need to know what the server does with the event. It fires and forgets. The server updates state, LiveView re-renders the affected entries, and the hook's updated() callback reapplies the selection ring wherever it belongs.

One small inconsistency worth calling out: toggle_read uses a hyphenated key "entry-id" in its payload, while mark_read uses an underscored "entry_id". This is a historical artifact of two different code paths that grew separately. Both work fine — Elixir's pattern matching operates on string keys and neither format is special in Phoenix. If you are starting from scratch, pick one convention and stick to it.


The reader view: CarouselKeyNav

The reader renders one entry at a time in a horizontal carousel. Navigation is simpler: left and right only, no selection ring to manage, no index state to track.

const CarouselKeyNav = {
  mounted() {
    this.handleKeyDown = (e) => {
      if (
        e.target.tagName === "INPUT" ||
        e.target.tagName === "TEXTAREA" ||
        e.target.tagName === "SELECT"
      )
        return;
      if (e.key === "ArrowRight") {
        e.preventDefault();
        this.pushEvent("carousel_next", {});
      }
      if (e.key === "ArrowLeft") {
        e.preventDefault();
        this.pushEvent("carousel_prev", {});
      }
    };
    window.addEventListener("keydown", this.handleKeyDown);
  },
  destroyed() {
    window.removeEventListener("keydown", this.handleKeyDown);
  },
};

The same structural patterns apply: input guard, saved bound reference, cleanup in destroyed(). The only difference worth noting is that CarouselKeyNav listens on window rather than document. In the reader view there is no outer scrollable container to worry about, and window is slightly more reliable for intercepting events that child elements might otherwise consume first. In practice for this use case the behaviour is identical to document.

Because there is no index state or visual ring, there is also no need for an updated() method. The hook's responsibility ends at delivering the event.


The e.preventDefault() gotcha

Arrow keys have a built-in browser behaviour: they scroll the page. Without e.preventDefault(), pressing ↓ in the entry list does two things simultaneously — it advances the selected entry and scrolls the viewport by one line height. The visual ring moves to the correct new entry, but the page also jumps, and the two movements do not align neatly. The result feels jittery and broken.

The fix is e.preventDefault() in the ArrowDown and ArrowUp cases of KeyboardNav, and in the ArrowLeft / ArrowRight cases of CarouselKeyNav. Both hooks already do this.

The important constraint is that we do not call preventDefault() for letter keys — j, k, m, o, r. Letter keys have no meaningful default browser behaviour in this context. Suppressing their defaults as a blanket habit would be wrong in principle and could cause unexpected interactions with browser-level keyboard shortcuts or assistive technology. Suppress only what genuinely needs suppressing.


The final shortcut reference

Key Action
j / ↓ Select next entry
k / ↑ Select previous entry
m Toggle read / unread on selected entry
o / Enter Open selected entry in a new tab (marks read)
r Refresh feeds
Escape Clear selection
← / → Navigate entries in reader / carousel view

The total JavaScript across both hooks is under 80 lines. The LiveView process handles all persistent state. The updated() lifecycle method keeps the visual selection synchronised after every server-side DOM patch. That combination — thin client, robust sync, clean teardown — is the pattern to reach for any time you need keyboard interaction in a LiveView application.