turbo_presence
Figma-style live cursors, avatar stacks, and typing indicators for Rails. One line.
[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:
- Stimulus controller connects to an Action Cable channel on mount, identified by a signed room token (model class + id)
mousemoveevents are captured, normalized to element-relative 0.0–1.0 coordinates, throttled to 50ms, and broadcast to all subscribers- Presence store (Redis-backed, memory fallback) tracks who's in each room with a 60s TTL — stale connections auto-expire
- Remote cursors are rendered as absolutely-positioned DOM elements, coordinates denormalized to the local element bounds
- 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
```erb <%# app/views/documents/show.html.erb %>Kanban board — per-card presence
```erb <%# app/views/cards/_card.html.erb %><%= card.title %>
<%= turbo_presence_for(card, cursors: false) %> <%# avatars only — no cursors on small cards %>Live dashboard — who's watching
```erb <%# app/views/dashboards/show.html.erb %>Sales Dashboard
<%= turbo_presence_for(@dashboard, typing: false) %> <%# cursors + avatars, no typing %>Custom user identity with colors
```ruby # 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
git clone https://github.com/jibranusman95/turbo_presence
cd turbo_presence
bundle install
bundle exec rspec
bundle exec rubocop
See CONTRIBUTING.md for full guidelines.
Further Reading
- How I built Google Docs cursors in Rails with 1 line (coming soon)
License
MIT. See LICENSE.