Skip to content

Scrolling Containers ​

When layouts contain lists or large blocks of content, they can overflow. RTXUI supports full horizontal and vertical scrolling with visual scrollbars.

Enabling Scroll ​

By adding overflow-y: scroll (or overflow-x: scroll) and restricting the dimensions (height or width), RTXUI automatically crops overflowing elements and displays a modern, responsive scrollbar.

cpp
class ScrollBox : public Component<ScrollBox> {
 public:
  std::string_view view = R"html(
      <div class="scroll-container">
        <div>Item 1</div>
        <div>Item 2</div>
        <div>Item 3</div>
        <!-- ... -->
      </div>
      <style>
        .scroll-container {
          display: block;
          height: 5;
          overflow-y: scroll;
          scrollbar-width: auto;
          border: wide;
          border-color: rgb(29, 78, 216);
        }
      </style>
    )html";
};

Event Bubbling ​

Scroll events automatically bubble up to nested containers when boundaries are reached, allowing for natural feeling nested scroll areas.

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// Nested scroll containers and scroll chaining.
//
// An inner container consumes wheel events until it reaches its end, then the
// event propagates to its parent.
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

class NestedScrollDemo : public Component<NestedScrollDemo> {
 public:
  static std::vector<std::string> Numbered(std::string prefix, int n) {
    std::vector<std::string> out;
    for (int i = 1; i <= n; ++i) {
      out.push_back(prefix + " " + std::to_string(i));
    }
    return out;
  }

  std::vector<std::string> before = Numbered("Outer line", 6);
  std::vector<std::string> inner = Numbered("Inner line", 15);
  std::vector<std::string> after = {"Outer line 7", "Outer line 8",
                                    "Outer line 9", "Outer line 10"};

  std::string_view view = R"html(
      <div class="card">
        <h1>Nested scrolling</h1>
        <p>The wheel scrolls the box under the mouse. Once that box reaches an end, it passes the scroll on to its parent.</p>

        <div class="outer">
          <for each="{before}" as="line">
            <div>{line}</div>
          </for>
          <div class="inner">
            <for each="{inner}" as="line">
              <div>{line}</div>
            </for>
          </div>
          <for each="{after}" as="line">
            <div>{line}</div>
          </for>
        </div>
      </div>

      <style>
        self {
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          color: rgb(230, 237, 243);
        }
        .card {
          border: tall;
          border-color: var(--border);
          background-color: rgb(22, 27, 34);
          padding: 1 3;
          width: 56;
        }
        h1 {
          color: var(--accent);
        }
        p {
          color: var(--muted);
        }
        .outer {
          height: 14;
          overflow-y: scroll;
          border: round;
          border-color: var(--border);
          padding: 0 1;
        }
        .inner {
          height: 6;
          overflow-y: scroll;
          border: tall;
          border-color: rgb(188, 140, 255);
          background-color: rgb(30, 24, 45);
          padding: 0 1;
          margin: 1 0;
        }
      </style>
    )html";

  NestedScrollDemo() {
    Bind(before);
    Bind(inner);
    Bind(after);
  }
};

int main() {
  auto app = Ref<NestedScrollDemo>::New();
  Screen screen(app);
  screen.Loop();
  return 0;
}

Scroll-Into-View on Keyboard Focus ​

RTXUI automatically scrolls containers to keep focused elements visible when users navigate using the keyboard (Tab and Shift+Tab).

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// Scroll-into-view on keyboard focus.
//
// Tabbing to an element that is outside its scroll container scrolls it into
// view automatically.
//
// Try it: hold Tab and watch the list follow the focus ring.
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

class FocusScrollDemo : public Component<FocusScrollDemo> {
 public:
  std::vector<std::string> items = {
      "Inbox", "Drafts", "Sent",   "Archive",  "Spam",        "Trash",
      "Work",  "Family", "Travel", "Receipts", "Newsletters", "Later",
  };

  std::string_view view = R"html(
      <div class="card">
        <h1>Focus scrolls into view</h1>
        <p>Press Tab or Shift+Tab: the list scrolls to keep the focused item visible.</p>

        <div class="list">
          <for each="{items}" as="item">
            <div class="item" tabindex="0">{item}</div>
          </for>
        </div>
      </div>

      <style>
        self {
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          color: rgb(230, 237, 243);
        }
        .card {
          border: tall;
          border-color: var(--border);
          background-color: rgb(22, 27, 34);
          padding: 1 3;
          width: 50;
        }
        h1 {
          color: var(--accent);
        }
        p {
          color: var(--muted);
        }
        .list {
          height: 8;
          overflow-y: scroll;
          border: round;
          border-color: var(--border);
        }
        .item {
          padding: 0 1;
        }
        .item:hover {
          background-color: rgb(33, 38, 45);
        }
        .item:focus {
          background-color: rgb(22, 45, 77);
          color: var(--accent);
        }
      </style>
    )html";

  FocusScrollDemo() { Bind(items); }
};

int main() {
  auto app = Ref<FocusScrollDemo>::New();
  Screen screen(app);
  screen.Loop();
  return 0;
}

Horizontal Scrolling ​

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// Horizontal overflow.
//
// overflow-x on a container that is narrower than its content produces a
// horizontal scrollbar.
//
// Try it: scroll with Shift+wheel, or drag the scrollbar.
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

class HorizontalScrollDemo : public Component<HorizontalScrollDemo> {
 public:
  static std::vector<std::string> Numbered(std::string prefix, int n) {
    std::vector<std::string> out;
    for (int i = 1; i <= n; ++i) {
      out.push_back(prefix + " " + std::to_string(i));
    }
    return out;
  }

  std::vector<std::string> outer = Numbered("Item", 3);
  std::vector<std::string> inner = Numbered("In", 8);
  std::vector<std::string> more = {"Item 4", "Item 5", "Item 6", "Item 7"};

  std::string_view view = R"html(
      <div class="card">
        <h1>Horizontal scrolling</h1>
        <p>overflow-x: scroll on a row wider than its box. Shift+wheel, drag the bar, or focus it and use the arrows. The inner row scrolls on its own until it reaches an end.</p>

        <div class="outer" tabindex="0">
          <for each="{outer}" as="item">
            <div class="tile">{item}</div>
          </for>
          <div class="inner" tabindex="0">
            <for each="{inner}" as="item">
              <div class="tile small">{item}</div>
            </for>
          </div>
          <for each="{more}" as="item">
            <div class="tile">{item}</div>
          </for>
        </div>
      </div>

      <style>
        self {
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          color: rgb(230, 237, 243);
        }
        .card {
          border: tall;
          border-color: var(--border);
          background-color: rgb(22, 27, 34);
          padding: 1 3;
          width: 64;
        }
        h1 {
          color: var(--accent);
        }
        p {
          color: var(--muted);
        }
        .outer, .inner {
          display: flex;
          gap: 1;
          overflow-x: scroll;
        }
        .outer {
          border: round;
          border-color: var(--border);
          padding: 0 1;
        }
        .outer:focus, .inner:focus {
          border-color: var(--accent);
        }
        .inner {
          flex-shrink: 0;
          width: 30;
          border: tall;
          border-color: rgb(52, 36, 79);
          background-color: rgb(30, 24, 45);
        }
        .tile {
          flex-shrink: 0;
          width: 12;
          border: tall;
          border-color: var(--border);
          background-color: rgb(13, 17, 23);
          padding: 1;
        }
        .small {
          width: 8;
          padding: 0 1;
          border-color: rgb(188, 140, 255);
        }
      </style>
    )html";

  HorizontalScrollDemo() {
    Bind(outer);
    Bind(inner);
    Bind(more);
  }
};

int main() {
  auto app = Ref<HorizontalScrollDemo>::New();
  Screen screen(app);
  screen.Loop();
  return 0;
}

Scroll Behavior ​

scroll-behavior: smooth makes a scroll container glide, rather than jump, to what it is asked to show: the target of an anchor link, or an element focused with Tab or the arrow keys. Scrolling with the mouse wheel or the scrolling keys is always immediate, so it keeps up with the hand.

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// scroll-behavior: smooth versus auto.
//
// scroll-behavior decides how a box scrolls to show something it was asked
// to: an anchor link's target, or an element focused from the keyboard.
// `smooth` glides there, `auto` jumps. The mouse wheel and the scrolling keys
// always scroll at once, whatever the box says.
//
// Try it: click the jump links above each box, or Tab through the items, and
// compare how each column travels.
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

class ScrollBehaviorDemo : public Component<ScrollBehaviorDemo> {
 public:
  std::vector<std::string> items = {
      "Item 2", "Item 3", "Item 4", "Item 5",  "Item 6",
      "Item 7", "Item 8", "Item 9", "Item 10", "Item 11",
  };

  std::string_view view = R"html(
      <div class="card">
        <h1>scroll-behavior</h1>
        <p>How a box scrolls to show a link's target or a focused item: auto jumps, smooth glides. The wheel and the keys always scroll at once.</p>

        <div class="row">
          <div class="column">
            <h2>auto</h2>
            <div class="links">
              <a href="#auto-last">↓ Last item</a>
              <a href="#auto-first">↑ First item</a>
            </div>
            <div class="box auto">
              <div class="item" id="auto-first" tabindex="0">First item</div>
              <for each="{items}" as="item">
                <div class="item" tabindex="0">{item}</div>
              </for>
              <div class="item" id="auto-last" tabindex="0">Last item</div>
            </div>
          </div>
          <div class="column">
            <h2>smooth</h2>
            <div class="links">
              <a href="#smooth-last">↓ Last item</a>
              <a href="#smooth-first">↑ First item</a>
            </div>
            <div class="box smooth">
              <div class="item" id="smooth-first" tabindex="0">First item</div>
              <for each="{items}" as="item">
                <div class="item" tabindex="0">{item}</div>
              </for>
              <div class="item" id="smooth-last" tabindex="0">Last item</div>
            </div>
          </div>
        </div>
      </div>

      <style>
        self {
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          color: rgb(230, 237, 243);
        }
        .card {
          border: tall;
          border-color: var(--border);
          background-color: rgb(22, 27, 34);
          padding: 1 3;
          width: 64;
        }
        h1 {
          color: var(--accent);
        }
        p {
          color: var(--muted);
        }
        .row {
          display: flex;
          gap: 3;
        }
        .column {
          flex: 1;
        }
        h2 {
          margin-bottom: 0;
        }
        .links {
          display: flex;
          justify-content: space-between;
        }
        a {
          color: var(--accent);
          text-decoration: none;
        }
        a:hover, a:focus {
          text-decoration: underline;
        }
        .box {
          height: 10;
          overflow-y: scroll;
          border: round;
          border-color: var(--border);
        }
        .auto {
          scroll-behavior: auto;
        }
        .smooth {
          scroll-behavior: smooth;
        }
        .item {
          padding: 0 1;
        }
        .item:focus {
          background-color: rgb(22, 45, 77);
          color: var(--accent);
        }
      </style>
    )html";

  ScrollBehaviorDemo() { Bind(items); }
};

int main() {
  auto component = Ref<ScrollBehaviorDemo>::New();
  Screen screen(component);
  screen.Loop();
  return 0;
}

Anchor Navigation ​

RTXUI supports anchor navigation using <a> tags. When an <a> tag with a target hash (e.g. href="#sec-intro") is clicked, RTXUI automatically scrolls the element with the corresponding id (e.g. id="sec-intro") into view. Combined with position: sticky and scroll-behavior: smooth, this allows building robust sidebar navigation layouts.

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// Anchor navigation.
//
// An <a href="#id"> scrolls its target into view inside the nearest scroll
// container.
//
// Try it: click the links in the index to jump between sections.
#include <rtxui/rtxui.hpp>
#include <string_view>

using namespace rtxui;

class AnchorDemo : public Component<AnchorDemo> {
 public:
  std::string_view view = R"html(
      <div class="card">
        <h1>Anchors</h1>
        <p>A link to "#id" scrolls that element into view. Click one, or Tab to it and press Enter.</p>

        <div class="workspace">
          <div class="index">
            <a href="#intro">Introduction</a>
            <a href="#install">Installation</a>
            <a href="#usage">Usage</a>
            <a href="#styling">Styling</a>
            <a href="#license">License</a>
          </div>

          <div class="pane">
            <div id="intro" class="section">
              <h2>Introduction</h2>
              RTXUI builds terminal interfaces from HTML templates and CSS, bound to C++ members.
            </div>
            <div id="install" class="section">
              <h2>Installation</h2>
              Add it with CMake's FetchContent, then link your target with rtxui::rtxui.
            </div>
            <div id="usage" class="section">
              <h2>Usage</h2>
              Derive a component, give it a view, bind its members, and run a Screen.
            </div>
            <div id="styling" class="section">
              <h2>Styling</h2>
              A style block in the view holds the CSS: flexbox, grid, borders, transitions.
            </div>
            <div id="license" class="section">
              <h2>License</h2>
              MIT.
            </div>
          </div>
        </div>
      </div>

      <style>
        self {
          --border: rgb(48, 54, 61);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          color: rgb(230, 237, 243);
        }
        .card {
          border: tall;
          border-color: var(--border);
          background-color: rgb(22, 27, 34);
          padding: 1 3;
          width: 70;
        }
        h1 {
          color: var(--accent);
        }
        p {
          color: var(--muted);
        }
        .workspace {
          display: flex;
          gap: 2;
        }
        .index {
          display: flex;
          flex-direction: column;
          width: 14;
          flex-shrink: 0;
        }
        a {
          color: var(--accent);
          text-decoration: none;
        }
        a:hover, a:focus {
          text-decoration: underline;
        }
        .pane {
          flex-grow: 1;
          height: 12;
          overflow-y: scroll;
          scroll-behavior: smooth;
          border-left: solid;
          border-color: var(--border);
          padding-left: 2;
        }
        .section {
          margin-bottom: 4;
        }
        h2 {
          color: var(--accent);
          margin-bottom: 0;
        }
      </style>
    )html";
};

int main() {
  auto app = Ref<AnchorDemo>::New();
  Screen screen(app);
  screen.Loop();
  return 0;
}

An application using all of it ​

A file browser: the listing scrolls, its rows are focusable, and moving the focus walks them while keeping the focused row on screen.

Fullscreen Demo
cpp
// Copyright 2026 Arthur Sonzogni. All rights reserved.
// Use of this source code is governed by the MIT license that can be found in
// the LICENSE file.
//
// A complete application driven by the keyboard.
//
// Where app_dashboard.cpp is about composing layout and state, this one is
// about the two things a terminal UI lives or dies by: scrolling a list that
// is longer than the viewport, and moving a selection through it without a
// mouse. Rows carry `tabindex`, so RTXUI's built-in focus navigation walks
// them and scrolls the focused one into view; the preview pane re-renders
// from computed values as the selection moves.
//
// Try it: move with the arrow keys (or j/k via the buttons), Enter to open a
// directory, Backspace to go up. The list scrolls to follow the selection.
#include <cstddef>
#include <rtxui/rtxui.hpp>
#include <string>
#include <string_view>
#include <vector>

using namespace rtxui;

namespace {

struct FileEntry {
  std::string name;
  std::string kind;  // "dir" | "file"
  std::string size;
  std::string modified;
  bool selected = false;

  bool operator==(const FileEntry& other) const = default;
};

// A small fixed tree, so the example stays self-contained and deterministic
// rather than depending on whatever happens to be on disk.
struct Directory {
  std::string path;
  std::vector<FileEntry> entries;
};

const std::vector<Directory>& Tree() {
  static const std::vector<Directory> tree = {
      {"/",
       {
           {"src", "dir", "-", "2026-08-01"},
           {"docs", "dir", "-", "2026-07-28"},
           {"example", "dir", "-", "2026-08-09"},
           {"CMakeLists.txt", "file", "14 kB", "2026-08-09"},
           {"README.md", "file", "6.2 kB", "2026-08-09"},
           {"LICENSE", "file", "1.1 kB", "2026-01-12"},
       }},
      {"/src",
       {
           {"rtxui", "dir", "-", "2026-08-09"},
           {"dummy.cpp", "file", "42 B", "2026-03-04"},
       }},
      {"/docs",
       {
           {"guide", "dir", "-", "2026-08-09"},
           {"index.md", "file", "2.4 kB", "2026-08-09"},
           {"css_reference.md", "file", "31 kB", "2026-08-09"},
           {"html_reference.md", "file", "18 kB", "2026-08-09"},
           {"reactivity.md", "file", "7.8 kB", "2026-08-09"},
       }},
      {"/example",
       {
           {"app_dashboard.cpp", "file", "9.1 kB", "2026-08-09"},
           {"app_filebrowser.cpp", "file", "8.4 kB", "2026-08-09"},
           {"counter.cpp", "file", "2.8 kB", "2026-08-09"},
           {"helloworld.cpp", "file", "1.4 kB", "2026-08-09"},
           {"playground.cpp", "file", "7.0 kB", "2026-08-09"},
           {"transitions.cpp", "file", "2.6 kB", "2026-08-09"},
           {"grid.cpp", "file", "2.2 kB", "2026-08-09"},
           {"lists.cpp", "file", "3.9 kB", "2026-08-09"},
           {"table.cpp", "file", "6.1 kB", "2026-08-09"},
           {"tooltip.cpp", "file", "4.3 kB", "2026-08-09"},
           {"sticky.cpp", "file", "4.6 kB", "2026-08-09"},
           {"markdown.cpp", "file", "6.8 kB", "2026-08-09"},
       }},
  };
  return tree;
}

}  // namespace

class FileBrowser : public Component<FileBrowser> {
 public:
  std::string path = "/";
  int cursor = 0;

  std::vector<FileEntry> entries = Tree().front().entries;

  // --- Computed values -----------------------------------------------------

  std::string current_path() const { return path; }
  std::string entry_count() const {
    return std::to_string(entries.size()) + " items";
  }

  std::string selected_name() const {
    const FileEntry* entry = Selected();
    return entry ? entry->name : "-";
  }
  std::string selected_kind() const {
    const FileEntry* entry = Selected();
    return entry ? (entry->kind == "dir" ? "Directory" : "File") : "-";
  }
  std::string selected_size() const {
    const FileEntry* entry = Selected();
    return entry ? entry->size : "-";
  }
  std::string selected_modified() const {
    const FileEntry* entry = Selected();
    return entry ? entry->modified : "-";
  }
  bool selected_is_dir() const {
    const FileEntry* entry = Selected();
    return entry && entry->kind == "dir";
  }
  bool can_go_up() const { return path != "/"; }

  // --- Handlers ------------------------------------------------------------

  void MoveDown() { SetCursor(cursor + 1); }
  void MoveUp() { SetCursor(cursor - 1); }
  void Select(const std::string& index) { SetCursor(std::stoi(index)); }

  void Open() {
    const FileEntry* entry = Selected();
    if (!entry || entry->kind != "dir") {
      return;
    }
    std::string next =
        path == "/" ? "/" + entry->name : path + "/" + entry->name;
    Navigate(next);
  }

  void GoUp() {
    if (path == "/") {
      return;
    }
    size_t slash = path.find_last_of('/');
    Navigate(slash == 0 ? "/" : path.substr(0, slash));
  }

  std::string_view view = R"html(
      <div class="app">
        <div class="header">
          <span class="brand">FILES</span>
          <span class="path">{current_path}</span>
          <span class="spacer"></span>
          <span class="count">{entry_count}</span>
        </div>

        <div class="body">
          <div class="listing" tabindex="0">
            <for each="{entries}" as="entry">
              <div class="row {entry.row_class}" tabindex="0" onclick="Select({$index})">
                <span class="glyph {entry.kind}">{entry.glyph}</span>
                <span class="name">{entry.name}</span>
                <span class="size">{entry.size}</span>
                <span class="modified">{entry.modified}</span>
              </div>
            </for>
          </div>

          <div class="preview">
            <span class="preview-name">{selected_name}</span>
            <span class="preview-kind">{selected_kind}</span>

            <div class="kv"><span class="k">Size</span><span class="v">{selected_size}</span></div>
            <div class="kv"><span class="k">Modified</span><span class="v">{selected_modified}</span></div>

            <if condition="{selected_is_dir}">
              <button class="primary" onclick="Open">Open directory</button>
            </if>
            <if condition="{can_go_up}">
              <button onclick="GoUp">Go up</button>
            </if>
          </div>
        </div>

        <div class="footer">
          <button class="key" onclick="MoveUp">k / up</button>
          <button class="key" onclick="MoveDown">j / down</button>
          <span class="spacer"></span>
          <span class="hint">the list scrolls to keep the selection visible</span>
        </div>
      </div>

      <style>
        self {
          --bg: rgb(13, 17, 23);
          --surface: rgb(22, 27, 34);
          --raised: rgb(31, 38, 47);
          --border: rgb(48, 54, 61);
          --text: rgb(230, 237, 243);
          --muted: rgb(139, 148, 158);
          --accent: rgb(88, 166, 255);

          display: block;
          width: 100%;
          height: 100%;
          background-color: var(--bg);
          color: var(--text);
        }
        .app {
          display: flex;
          flex-direction: column;
          height: 100%;
        }

        .header {
          display: flex;
          align-items: center;
          gap: 2;
          background-color: var(--surface);
          border-bottom: solid;
          border-color: var(--border);
          padding: 0 2;
        }
        .brand {
          color: var(--accent);
          font-weight: bold;
        }
        .path {
          color: var(--text);
        }
        .count {
          color: var(--muted);
        }
        .spacer {
          flex-grow: 1;
        }

        .body {
          display: flex;
          flex-grow: 1;
          gap: 1;
          padding: 1 2;
        }

        /* The list is deliberately shorter than its content: scroll-behavior
           smooth plus the browser-style scroll-into-view on focus is what
           keeps the selected row visible as the cursor moves. */
        .listing {
          display: flex;
          flex-direction: column;
          flex-grow: 1;
          border: tall;
          border-color: var(--border);
          background-color: var(--surface);
          padding: 0 1;
          overflow-y: scroll;
          scroll-behavior: smooth;
        }
        .row {
          display: flex;
          gap: 1;
          align-items: center;
          padding: 0 1;
          transition: background-color 0.15s ease;
        }
        .row:hover {
          background-color: var(--raised);
        }
        .row.selected, .row:focus {
          background-color: var(--raised);
          border-left: tall;
          border-color: var(--accent);
        }
        .glyph.dir {
          color: rgb(210, 153, 34);
        }
        .glyph.file {
          color: var(--muted);
        }
        .name {
          flex-grow: 1;
        }
        .size {
          width: 10;
          color: var(--muted);
          text-align: right;
        }
        .modified {
          width: 12;
          color: var(--muted);
          text-align: right;
        }

        .preview {
          display: flex;
          flex-direction: column;
          border: tall;
          border-color: var(--border);
          background-color: var(--surface);
          padding: 1 2;
          width: 30;
        }
        .preview-name {
          color: var(--accent);
          font-weight: bold;
        }
        .preview-kind {
          color: var(--muted);
          margin-bottom: 1;
        }
        .kv {
          display: flex;
          justify-content: space-between;
          width: 100%;
        }
        .k {
          color: var(--muted);
        }
        .v {
          font-weight: bold;
        }

        button {
          border: tall;
          border-color: var(--border);
          background-color: var(--bg);
          color: var(--text);
          padding: 0 1;
          margin-top: 1;
          text-align: center;
          transition: background-color 0.15s ease, border-color 0.15s ease,
                      color 0.15s ease;
        }
        button:hover, button:focus {
          border-color: var(--accent);
          color: var(--accent);
        }
        button:active {
          background-color: var(--accent);
          color: var(--bg);
        }
        button.primary {
          border-color: var(--accent);
          color: var(--accent);
        }

        .footer {
          display: flex;
          align-items: center;
          gap: 2;
          border-top: solid;
          border-color: var(--border);
          background-color: var(--surface);
          color: var(--muted);
          padding: 0 2;
        }
        .footer button.key {
          margin-top: 0;
        }
        .hint {
          color: var(--border);
        }
      </style>
    )html";

  FileBrowser() {
    BindCollection("entries", &entries,
                   [](const FileEntry& entry, FieldSink& out) {
                     out("name", entry.name);
                     out("kind", entry.kind);
                     out("size", entry.size);
                     out("modified", entry.modified);
                     out("glyph", entry.kind == "dir" ? "/" : ".");
                     out("row_class", entry.selected ? "selected" : "");
                   });

    Bind(path);
    Bind(cursor);
    Bind(current_path);
    Bind(entry_count);
    Bind(selected_name);
    Bind(selected_kind);
    Bind(selected_size);
    Bind(selected_modified);
    Bind(selected_is_dir);
    Bind(can_go_up);
    Bind(MoveDown);
    Bind(MoveUp);
    Bind(Open);
    Bind(GoUp);
    Bind(Select);

    SetCursor(0);
  }

  // Arrow keys drive the cursor; Enter opens; Backspace goes up. Returning
  // true marks the event handled so it doesn't also reach the focus system.
  bool OnEvent(Event event) override {
    if (!event.is<Event::Keyboard>()) {
      return Component<FileBrowser>::OnEvent(event);
    }
    auto key = event.get<Event::Keyboard>();
    if (key.motion() != Event::Keyboard::Motion::Pressed &&
        key.motion() != Event::Keyboard::Motion::Repeat) {
      return Component<FileBrowser>::OnEvent(event);
    }

    switch (key.special()) {
      case Event::Keyboard::Special::ArrowDown:
        MoveDown();
        return false;  // let focus move too, so the list scrolls to follow
      case Event::Keyboard::Special::ArrowUp:
        MoveUp();
        return false;
      case Event::Keyboard::Special::Return:
        Open();
        return true;
      case Event::Keyboard::Special::Backspace:
        GoUp();
        return true;
      default:
        break;
    }
    if (key.codepoint() == 'j') {
      MoveDown();
      return true;
    }
    if (key.codepoint() == 'k') {
      MoveUp();
      return true;
    }
    return Component<FileBrowser>::OnEvent(event);
  }

 private:
  const FileEntry* Selected() const {
    if (cursor < 0 || cursor >= static_cast<int>(entries.size())) {
      return nullptr;
    }
    return &entries[cursor];
  }

  void SetCursor(int index) {
    if (index < 0 || index >= static_cast<int>(entries.size())) {
      return;
    }
    for (FileEntry& entry : entries) {
      entry.selected = false;
    }
    entries[index].selected = true;
    cursor = index;
  }

  void Navigate(const std::string& next) {
    for (const Directory& directory : Tree()) {
      if (directory.path == next) {
        path = next;
        entries = directory.entries;
        SetCursor(0);
        return;
      }
    }
  }
};

int main() {
  auto app = Ref<FileBrowser>::New();
  Screen screen(app);
  screen.SetInline(false);
  screen.Loop();
  return 0;
}