0.0
The project is in a healthy, maintained state
turbo_presence spins up real-time multi-user presence in any Rails + Hotwire app. Drop <%= turbo_presence_for(@document) %> into any view and get live cursors, avatar stacks, and typing indicators — built on Action Cable, zero JavaScript required.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

turbo_presence

Figma-style live cursors, avatar stacks, and typing indicators for Rails. One line.

CI Gem Version Downloads License: MIT


[GIF: two browser windows side by side — cursor moves on the left, appears instantly on the right. User starts typing, "Alice is typing…" appears. Second user joins, avatar appears in stack. First user leaves, avatar disappears. 8 seconds. No words needed.]


Your users are already in the same document at the same time. They just can't see each other.

Liveblocks costs $200/month and requires you to rewrite your frontend in JavaScript. Phoenix has presence built in. Rails has nothing.

Until now.

<%# That's it. Cursors, avatars, typing indicators. Done. %>
<%= turbo_presence_for(@document) %>

No JavaScript to write. No third-party service. No monthly bill. Built on Action Cable — the thing already in your Rails app.


What you get

Live cursors — every user's mouse position, in real time, element-relative so it works on every screen size.

Avatar stack — see who's viewing the same record right now. Updates instantly when someone joins or leaves.

Typing indicators — "Alice is typing…" fires when a user is active, clears automatically when they stop.

Zero config for Devise users — one initializer line and you're done.


Install

# Gemfile
gem "turbo_presence"
bundle install
rails turbo_presence:install
# config/initializers/turbo_presence.rb
TurboPresence.configure do |config|
  config.identify_user { |user| { id: user.id, name: user.name } }
end
<%# app/views/documents/show.html.erb %>
<%= turbo_presence_for(@document) %>

<%= form_with model: @document do |f| %>
  <%= f.text_area :content %>
<% end %>

That's the entire integration. Ship it.


How it looks

┌──────────────────────────────────────────────────────┐
│  📄 Quarterly Report                                 │
│                                                      │
│  👤 Alice  👤 Bob  +2        ← avatar stack         │
│  Alice is typing…            ← typing indicator      │
│                                                      │
│  The Q3 numbers show▌                                │
│            ↖ Bob             ← live cursor           │
└──────────────────────────────────────────────────────┘

Full API

View helper

<%# Basic — cursors + avatars + typing %>
<%= turbo_presence_for(@document) %>

<%# Custom container %>
<%= turbo_presence_for(@document, class: "my-presence-bar") %>

<%# Disable specific features %>
<%= turbo_presence_for(@document, cursors: false, typing: false) %>

Configuration

TurboPresence.configure do |config|
  # Required — return a hash identifying the current user
  config.identify_user do |user|
    {
      id:     user.id,
      name:   user.display_name,
      avatar: user.avatar_url,   # optional
      color:  user.presence_color # optional — auto-assigned if omitted
    }
  end

  # Optional — Redis URL (auto-detected from ENV["REDIS_URL"] if present)
  config.redis_url = "redis://localhost:6379/1"

  # Optional — how long before a stale presence entry expires (default: 60s)
  config.presence_ttl = 60

  # Optional — cursor throttle in milliseconds (default: 50ms)
  config.cursor_throttle_ms = 50
end

JavaScript hooks (optional)

// Listen to presence events in your own JS if needed
document.addEventListener("turbo-presence:join", (e) => {
  console.log(`${e.detail.name} joined`)
})

document.addEventListener("turbo-presence:leave", (e) => {
  console.log(`${e.detail.name} left`)
})

document.addEventListener("turbo-presence:cursor", (e) => {
  console.log(`${e.detail.name} moved to`, e.detail.x, e.detail.y)
})

Why not just use Liveblocks / PartyKit / WebSockets directly?

turbo_presence Liveblocks PartyKit Roll your own
Cost Free $25–$200/mo Pay per connection Free
Rails-native
Zero JS
Works with Devise Manual Manual Manual
Action Cable
Setup time 5 min Hours Hours Days
Vendor lock-in None High High None

Phoenix LiveView has presence built in. Rails deserves the same.


How it works

turbo_presence_for(@document) renders a Stimulus controller mount point scoped to Document#42 (or whatever record you pass). Under the hood:

  1. Stimulus controller connects to an Action Cable channel on mount, identified by a signed room token (model class + id)
  2. mousemove events are captured, normalized to element-relative 0.0–1.0 coordinates, throttled to 50ms, and broadcast to all subscribers
  3. Presence store (Redis-backed, memory fallback) tracks who's in each room with a 60s TTL — stale connections auto-expire
  4. Remote cursors are rendered as absolutely-positioned DOM elements, coordinates denormalized to the local element bounds
  5. On disconnect — the channel broadcasts a departure event, the avatar disappears, cursors are removed

Cursor coordinates are normalized (0.0 to 1.0 relative to the element) so they work identically on a 13" laptop and a 4K monitor.


Real-world examples

Collaborative document editor
<%# app/views/documents/show.html.erb %>
<div class="document-editor">
  <%= turbo_presence_for(@document) %>

  <%= form_with model: @document, data: { controller: "autosave" } do |f| %>
    <%= f.text_area :content, rows: 30 %>
  <% end %>
</div>
Kanban board — per-card presence
<%# app/views/cards/_card.html.erb %>
<div class="card">
  <h3><%= card.title %></h3>
  <%= turbo_presence_for(card, cursors: false) %>  <%# avatars only — no cursors on small cards %>
</div>
Live dashboard — who's watching
<%# app/views/dashboards/show.html.erb %>
<div class="dashboard-header">
  <h1>Sales Dashboard</h1>
  <%= turbo_presence_for(@dashboard, typing: false) %>  <%# cursors + avatars, no typing %>
</div>
Custom user identity with colors
# config/initializers/turbo_presence.rb
TurboPresence.configure do |config|
  config.identify_user do |user|
    {
      id:     user.id,
      name:   user.full_name,
      avatar: url_for(user.avatar),
      color:  user.team_color || TurboPresence.auto_color(user.id)
    }
  end
end

System test helpers

# spec/support/turbo_presence.rb
require "turbo_presence/test_helpers"

RSpec.describe "document editing", type: :system do
  include TurboPresence::TestHelpers

  it "shows who is viewing the document" do
    using_session(:alice) { visit document_path(@document) }
    using_session(:bob)   { visit document_path(@document) }

    using_session(:alice) do
      expect(page).to have_presence_of("Bob")
    end
  end

  it "shows live cursors" do
    using_session(:alice) { visit document_path(@document) }
    using_session(:bob)   { visit document_path(@document) }

    simulate_cursor(x: 0.5, y: 0.3, as: :alice)

    using_session(:bob) do
      expect(page).to have_cursor_near(x: 0.5, y: 0.3, tolerance: 0.05)
    end
  end

  it "shows typing indicators" do
    using_session(:alice) { visit document_path(@document) }
    using_session(:bob) do
      visit document_path(@document)
      simulate_typing(as: :alice)
      expect(page).to have_text("Alice is typing…")
    end
  end
end

Requirements

  • Ruby >= 3.1
  • Rails >= 7.0
  • Action Cable
  • Hotwire / Turbo
  • Stimulus >= 3.0
  • Redis (optional — memory store used as fallback)

Contributing

I built this myself — which means it works great for the cases I thought of, and probably has rough edges for the ones I didn't. If you hit something weird, open an issue. I read them all and respond fast.

Want to fix something or add a feature? Send a PR. No CLA, no process overhead, no committee review. If the tests pass and the change makes sense, it's getting merged. I'm one person and I genuinely appreciate the help — you can take this further than I can alone.

Not sure where to start? Look for good first issue labels, or just open an issue and ask.

git clone https://github.com/jibranusman95/turbo_presence
cd turbo_presence
bundle install
bundle exec rspec    # all green? you're good to go
bundle exec rubocop  # no new offenses

See CONTRIBUTING.md for full guidelines.

Contributors

Everyone who's made this better:


Further Reading

  • How I built Google Docs cursors in Rails with 1 line (coming soon)

From the same author

Small, sharp Ruby gems built to the same standard — 100% test coverage, zero dependencies beyond what's needed.

Gem What it does
llm_cassette VCR for LLMs — streaming-aware cassette recorder for OpenAI and Anthropic
promptscrub PII redaction middleware for LLM calls — strip sensitive data from prompts, rehydrate in responses
http_decoy A real Rack server that runs inside your RSpec tests — test HTTP contracts, not stubs
webhook_inbox Transactional inbox for Rails webhook receivers — deduplication, async processing, replay, dashboard
agent_jail Fork-based sandbox for LLM tool calls — timeout, memory limit, and filesystem restrictions

License

MIT. See LICENSE.