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
endJavaScript 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
<%# 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><%# 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><%# 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># 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
endSystem 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
endRequirements
- 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 offensesSee 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.