The project is in a healthy, maintained state
Turbo-frame UI for active_storage-async_variants: image_tag/video_tag rendering, progress bars, and retry.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

ActiveStorage::AsyncVariants::UI

The opinionated UI layer for active_storage-async_variants: image_tag / video_tag rendering with async: / direct:, a <turbo-frame> that polls a processing variant to completion, a circular progress bar (with an error state), and an optional retry affordance — all served through an isolated asset pipeline.

The plumbing gem (active_storage-async_variants) is intentionally UI-free: a variant's .url serves the original while pending/processing/failed and the processed variant once ready. Add this gem when you want the turbo-frame/progress-bar/retry experience on top.

Installation

gem "active_storage-async_variants-ui"

This pulls in active_storage-async_variants (the plumbing) as a dependency. Run its migrations as documented there:

bin/rails active_storage_async_variants:install:migrations
bin/rails db:migrate

The only other runtime requirement is Turbo — the gem depends on turbo-rails, which a default Rails app already includes. No manual JS/CSS wiring: the async state partials are self-contained <turbo-frame>s and their CSS/JS are served from an isolated asset route.

image_tag / video_tag with async: and direct:

This gem adds two options to image_tag and video_tag:

<%# A progress bar while pending/processing, polls for completion,
    swaps to the real image when ready. %>
<%= image_tag user.avatar.variant(:web), async: true %>

<%# When the variant is ready, render its direct CDN/S3 URL instead of
    routing through Rails. While pending/failed, falls back to the
    Rails representation URL (which serves the original). %>
<%= image_tag user.avatar.variant(:web), direct: true %>

<%# Both together: direct URL once processed, progress bar while pending. %>
<%= image_tag user.avatar.variant(:web), async: true, direct: true %>

<%# Same for videos. %>
<%= video_tag user.video.variant(:web), async: true, controls: true %>

The first argument must be a VariantWithRecord or Preview when either option is set; otherwise image_tag / video_tag behave exactly as in stock Rails.

While a variant is processing, the partial renders a zero-network sized box (a tiny filename-bearing 1x1 GIF, or a source-less <video>) with the circular progress bar floating over it — indeterminate until progress is reported, then a determinate percentage that creeps forward between polls. Once a variant permanently fails, the same progress bar renders in its error state (a red ring with an X).

Configure the direct URL host

By default direct: uses the storage service's URL. To serve from a CDN, set the host:

# config/initializers/active_storage_async_variants.rb
ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"

The resulting URL is "#{cdn_host}/#{variant.key}".

Configuration

This gem reopens ActiveStorage::AsyncVariants, so its options sit alongside the plumbing gem's in a single configure block (each is also a plain accessor):

# config/initializers/active_storage_async_variants.rb
ActiveStorage::AsyncVariants.configure do |config|
  config.cdn_host          = "https://d1234abcd.cloudfront.net"
  config.parent_controller = "ApplicationController"
  config.retry_visible_if { current_user&.admin? }
end
Option Default Purpose
cdn_host nil Host for direct: URLs ("#{cdn_host}/#{variant.key}"); falls back to the storage service URL.
parent_controller "ActionController::Base" Base class for the gem's StatesController, so the retry view can reach your app's current_user. Set as a String.
retry_visible_if off Block (run in the view context) gating the failed-state retry affordance.

The heartbeat_interval (poll cadence) and heartbeat_stale_after options live in the plumbing gem; the processing <turbo-frame> re-polls at heartbeat_interval.

Retry affordance

When retry_visible_if returns truthy, a failed variant reveals (on hover) a control that opens a dialog with the error and a "Retry processing" button. The button destroys the failed VariantRecord and re-enqueues the transform via the variant's #enqueue!. The dialog's CSS/JS are served as cached assets from /active_storage/async_variants/assets/….

Overriding the partials

Apps can override any state partial by creating a same-named file in their own app/views/active_storage/async_variants/states/ (_processing, _processed, _failed, show).

License

MIT