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