Senren UI
Components / Product Card

Product Card

Stable

product listings

New

Ceramic pour-over

Matte stoneware, 500ml. Fires evenly and keeps heat.

$38.00

Burr grinder

Forty grind settings, stepless. Quiet enough for a shared office.

$129.00

Single-origin subscription

Rotating lots, shipped the day after roast.

$22.00

Usage example #

Copy this ERB into a Rails view after installing the component. The snippet below is the same code used by the live preview above.

app/views/.../product_card_example.html.erb
<div class="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-3">
  <%= render Senren::ProductCardComponent.new(
    title: "Ceramic pour-over",
    price: "$38.00",
    url: demo_url,
    description: "Matte stoneware, 500ml. Fires evenly and keeps heat.",
    badge: "New"
  ) %>

  <%= render Senren::ProductCardComponent.new(
    title: "Burr grinder",
    price: "$129.00",
    url: demo_url,
    description: "Forty grind settings, stepless. Quiet enough for a shared office.",
    action_label: "Add to bag"
  ) %>

  <%= render Senren::ProductCardComponent.new(
    title: "Single-origin subscription",
    price: "$22.00",
    url: demo_url,
    description: "Rotating lots, shipped the day after roast.",
    available: false
  ) %>
</div>

Install this component #

Copy the official component into your app

Use this when you want the Senren-maintained implementation copied into app/components/senren.

Terminal
bin/rails senren:add product_card

Create a custom component with the same conventions

Use this when you need an app-specific static component that follows Senren's ViewComponent structure.

Terminal
bin/rails generate senren:component product_card --no-client

At a glance #

Category Saas
Class name Senren::ProductCardComponent
Stimulus
Variants default, featured
Depends on
Pairs with cart, badge, button, card

Source #

app/components/senren/product_card_component.html.erb
<%= tag.article(**root_attrs("flex flex-col overflow-hidden rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id)) do %>
  <div class="relative aspect-square w-full bg-[hsl(var(--senren-muted)/0.4)]">
    <% if safe_image_url %>
      <%= image_tag safe_image_url, alt: title, loading: "lazy", class: "h-full w-full object-cover" %>
    <% else %>
      <div class="flex h-full w-full items-center justify-center text-sm text-[hsl(var(--senren-muted-foreground))]" aria-hidden="true">
        No image
      </div>
    <% end %>

    <% if badge %>
      <span class="absolute left-2 top-2 inline-flex items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-2 py-0.5 text-xs font-medium text-[hsl(var(--senren-primary-foreground))]">
        <%= badge %>
      </span>
    <% end %>
  </div>

  <div class="flex flex-1 flex-col gap-2 p-4">
    <h3 class="text-sm font-medium leading-tight text-[hsl(var(--senren-foreground))]">
      <%= title %>
    </h3>

    <% if description %>
      <p class="line-clamp-2 text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
    <% end %>

    <p class="mt-auto text-base font-semibold text-[hsl(var(--senren-foreground))]"><%= price %></p>

    <%# A plain form: the server owns the cart, and Turbo handles the response. %>
    <%= form_with url: safe_action_url, method: method, class: "mt-1" do %>
      <button type="submit"
              <% unless available? %>disabled<% end %>
              class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:cursor-not-allowed disabled:opacity-50">
        <%= button_label %>
      </button>
    <% end %>
  </div>
<% end %>
app/components/senren/product_card_component.rb
# frozen_string_literal: true

module Senren
  # A product tile with an add-to-cart action.
  #
  # Adding to a cart is an ordinary form submission, so this component ships no
  # Stimulus controller. That is deliberate: a listing page renders this tile
  # many times, and a controller here would download JavaScript on a page that
  # does not need any.
  #
  # Price is passed in already formatted. Currency formatting is locale- and
  # money-library-specific and belongs to the host app; a UI library that
  # guesses gets it wrong in another country.
  class ProductCardComponent < BaseComponent
    VARIANTS = {
      default: 'border-[hsl(var(--senren-border))]',
      featured: 'border-[hsl(var(--senren-primary))] ring-1 ring-[hsl(var(--senren-primary)/0.35)]'
    }.freeze

    SIZES = { md: '' }.freeze

    def initialize(title:, price:, url:, image_url: nil, description: nil, badge: nil,
                   action_label: 'Add to cart', available: true, method: :post,
                   variant: :default, id: nil, class_name: nil, **html)
      super(variant: variant, size: :md, class_name: class_name, **html)
      @title = title
      @price = price
      @url = url
      @image_url = image_url
      @description = description
      @badge = badge
      @action_label = action_label
      @available = available
      @method = method
      @dom_id = id || senren_dom_id(title)
    end

    attr_reader :title, :price, :image_url, :description, :badge, :action_label, :method, :dom_id

    def available? = @available == true

    # Untrusted values reach href/src attributes, so both go through the shared
    # guards rather than being interpolated directly.
    def safe_action_url = safe_url(@url)
    def safe_image_url = safe_media_url(@image_url)

    def button_label = available? ? action_label : 'Out of stock'
  end
end

AI agent rules #

Use for

  • +product listings
  • +catalogue grids
  • +storefront search results

Avoid

  • -carrying cart state; adding to a cart is a form submission the server owns

Accessibility #

  • Image alt text repeats the product title; decorative placeholder is aria-hidden.
  • Out of stock disables the submit button rather than hiding it.