Image Focal Points
Control how images crop across different aspect ratios with focal point selection and per-context overrides.
Overview
The focal point system has three layers:
- Global Configuration - Define image contexts (aspect ratios) at the application level
- Per-Image Focal Points - Set where the "important part" of each image is
- Per-Context Overrides - Optionally use custom crops or different images for specific contexts
Quick Start
1. Enable the Feature
Railspress.configure do |config|
config.enable_post_images # Enables header_image on Post model
config.enable_focal_points # Enables focal point editing UI
end
2. Run Migrations
$ rails railspress:install:migrations
$ rails db:migrate
This creates the railspress_focal_points table for storing focal point data.
3. Add JavaScript Import
import "railspress"
This auto-registers all RailsPress Stimulus controllers (focal point picker, dropzone, etc.).
4. Use in Views
<%= image_tag url_for(@post.header_image.variant(resize_to_limit: [800, 600])),
style: @post.focal_point_css(:header_image) %>
Configuration
Image Contexts
Define the aspect ratios your site uses:
Railspress.configure do |config|
# Default contexts (you can customize these)
config.image_contexts = {
hero: { aspect: [16, 9], label: "Hero Banner", sizes: [1920, 1280] },
card: { aspect: [4, 3], label: "Card", sizes: [800, 400] },
thumb: { aspect: [1, 1], label: "Thumbnail", sizes: [200] }
}
# Or add individual contexts
config.add_image_context :wide, aspect: [21, 9], label: "Ultra Wide"
config.add_image_context :portrait, aspect: [3, 4], label: "Portrait"
end
Database Schema
The focal point data is stored in a polymorphic table:
create_table :railspress_focal_points do |t|
t.references :record, polymorphic: true, null: false
t.string :attachment_name, null: false
t.decimal :focal_x, precision: 5, scale: 4, default: 0.5
t.decimal :focal_y, precision: 5, scale: 4, default: 0.5
t.json :overrides, default: {}
t.timestamps
end
| Column | Description |
|---|---|
record |
Polymorphic reference to the parent model (Post, Project, etc.) |
attachment_name |
Which attachment this focal point is for (e.g., "header_image") |
focal_x, focal_y |
Coordinates from 0.0 to 1.0 (0.5, 0.5 = center) |
overrides |
JSON hash of per-context overrides |
Adding to Custom Models
Posts automatically have focal points when enable_post_images and enable_focal_points are both enabled.
For custom models (Entities), include the HasFocalPoint concern:
class Project < ApplicationRecord
include Railspress::Entity
include Railspress::HasFocalPoint
has_one_attached :cover_image
has_one_attached :banner_image
has_focal_point :cover_image
has_focal_point :banner_image
end
No migrations needed on your model. The polymorphic railspress_focal_points table stores all focal point data.
Model API
Reading Focal Points
post = Post.find(1)
# Get focal point coordinates (returns hash)
post.focal_point(:header_image)
# => { x: 0.3, y: 0.7 }
# Get CSS for object-position
post.focal_point_css(:header_image)
# => "object-position: 30.0% 70.0%"
# Check if focal point is set (not center)
post.has_focal_point?(:header_image)
# => true
Context-Aware Image Selection
# Get the image for a specific context
# Returns the original attachment, or an uploaded override blob
image = post.image_for(:hero, :header_image)
# Get CSS for a specific context
# Handles focal points, crops, and uploads appropriately
css = post.image_css_for(:hero, :header_image)
Per-Context Overrides
# Check if context has a custom override
post.has_image_override?(:hero, :header_image)
# => true/false
# Get override details
post.image_override(:hero, :header_image)
# => { type: "crop", region: { x: 0.1, y: 0.2, width: 0.6, height: 0.5 } }
# Set an override
post.set_image_override(:hero, {
type: "crop",
region: { x: 0.1, y: 0.2, width: 0.6, height: 0.5 }
}, :header_image)
# Clear override (revert to using focal point)
post.clear_image_override(:hero, :header_image)
Override Types
| Type | Description |
|---|---|
focal |
Use the global focal point (default) |
crop |
Use a custom crop region for this context |
upload |
Use a completely different image for this context |
View Helpers
Basic Usage
<%# Apply focal point CSS to an image %>
<%= image_tag url_for(@post.header_image),
style: @post.focal_point_css(:header_image),
class: "object-cover w-full h-64" %>
Context-Aware Usage
<%# Hero section - might use focal point or custom crop %>
<div class="hero" style="aspect-ratio: 16/9;">
<% image = @post.image_for(:hero, :header_image) %>
<% if image.respond_to?(:variant) %>
<%= image_tag url_for(image.variant(resize_to_limit: [1920, 1080])),
style: @post.image_css_for(:hero, :header_image),
class: "object-cover w-full h-full" %>
<% elsif image.respond_to?(:url) %>
<%= image_tag url_for(image),
class: "object-cover w-full h-full" %>
<% end %>
</div>
Card Component Example
<%# Card with 4:3 aspect ratio %>
<article class="card">
<div class="card-image" style="aspect-ratio: 4/3;">
<%= image_tag url_for(@post.header_image.variant(resize_to_limit: [800, 600])),
style: @post.image_css_for(:card, :header_image),
class: "object-cover w-full h-full" %>
</div>
<div class="card-body">
<h3><%= @post.title %></h3>
</div>
</article>
Admin UI
Image Section Partial
Use the provided partial in your admin forms:
<%= form_with model: [:admin, @post] do |form| %>
<%# ... other fields ... %>
<% if Railspress.focal_points_enabled? %>
<%= render "railspress/admin/shared/image_section",
form: form,
record: @post,
attachment_name: :header_image,
label: "Featured Image" %>
<% end %>
<% end %>
Partial Options
| Option | Type | Default | Description |
|---|---|---|---|
form |
FormBuilder | required | Rails form builder |
record |
Model | required | Record with HasFocalPoint |
attachment_name |
Symbol | required | Attachment name (e.g., :header_image) |
label |
String | "Main Image" | Section label |
contexts |
Hash | Railspress.image_contexts |
Context configs for previews |
show_advanced |
Boolean | false | Show per-context override UI |
Controller Setup
Permit nested focal point attributes:
class Admin::PostsController < ApplicationController
def post_params
params.require(:post).permit(
:title, :content, :header_image, :remove_header_image,
header_image_focal_point_attributes: [:focal_x, :focal_y, :overrides]
)
end
end
How It Works
Display-Time Cropping
The focal point system uses CSS object-position for display-time cropping:
.image-container {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 30% 70%; /* Focal point at x=0.3, y=0.7 */
}
This means:
- Original images are never destructively cropped
- The same image works across all aspect ratios
- Focal point changes take effect immediately (no re-processing)
Architecture Decisions
Why Polymorphic Table? Instead of adding columns to each model:
- No migrations needed for host app models
- Consistent pattern across all entity types
- Mirrors ActionText approach (
action_text_rich_texts) - Single table for all focal point data
Why Display-Time Cropping? Instead of generating pre-cropped variants:
- Immediate updates - No waiting for image processing
- Single source - One image serves all contexts
- Reversible - Change focal point anytime
- Storage efficient - No duplicate variants per context
Troubleshooting
Focal Point Not Saving
Ensure nested attributes are permitted in your controller:
permitted.push(header_image_focal_point_attributes: [:focal_x, :focal_y, :overrides])
Stimulus Controllers Not Loading
Add the RailsPress import to your application.js:
import "railspress" // Auto-registers all RailsPress Stimulus controllers
This requires window.Stimulus to be set. If you use a custom Stimulus setup:
import { Application } from "@hotwired/stimulus"
import { register } from "railspress"
const application = Application.start()
window.Stimulus = application // Required for auto-registration
register(application) // Or register manually
Images Not Cropping Correctly
Ensure you're using object-fit: cover on the image:
img {
object-fit: cover;
width: 100%;
height: 100%;
}