DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Create a macOS-inspired stack UI with Stimulus and Tailwind CSS

This article was originally published on Rails Designer


The other day I accidentally enabled the “fan” option in my dock's application folder (I have it normally set to just "list"). But this incident inspired me to recreate the effect in Rails with a simple Stimulus controller and lots of Tailwind CSS goodies.

(side-note: an early reader of this article mentioned that Hey uses a similar effect for their “trays”)

It made for a good case to show how much can be done with (Tailwind-flavored) CSS. And while this article uses Tailwind CSS, it can be easily replicated with just CSS. 💡

This is the component I am aiming for:

What will be covered in this article:

  • Tailwind CSS grouping;
  • Data variants with group modifiers;
  • Using Stimulus FX for the whenOutside custom action.

As often the code can be found in the Github repo. 🚀

Let's go!

The foundation

First, let's quickly go over the foundation to understand how the simple data model works to display cards (it is not a typical Active Record model).

# app/models/card.rb
class Card
  include ActiveModel::Model

  attr_accessor :subject, :description
end
Enter fullscreen mode Exit fullscreen mode

The PagesController creates a few sample cards:

# app/controllers/pages_controller.rb
def show
  @cards = [
    Card.new(subject: "UI Components v1.15 out now 🎉",
             description: "Prepare the welcome package and schedule introductory sessions"),
    Card.new(subject: "AppRefresher (coming soon) ⚡",
             description: "AI-powered tool to keep your knowledge base articles images/screenshots up-to-date"),
    Card.new(subject: "In beta now: Helptail 🏗️",
             description: "A visual workflow builder that connects to any API, allowing you to automate repetitive tasks like drafting newsletters or publishing scheduled content (think a more developer-focused Zapier)")
  ]
end
Enter fullscreen mode Exit fullscreen mode

And a simple card partial to render each card:

<!-- app/views/cards/_card.html.erb -->
<li>
  <%= link_to "#" do %>
    <h5>
      <%= card.subject %>
    </h5>

    <p>
      <%= card.description %>
    </p>
  <% end %>
</li>
Enter fullscreen mode Exit fullscreen mode

The view simply renders these cards in an unordered list:

<!-- app/views/pages/show.html.erb -->
<ul>
  <%= render @cards %>
</ul>
Enter fullscreen mode Exit fullscreen mode

The Canvas

The first step is to create a visually appealing background and style the cards. Let's start by adding a gradient (taken from the Tailwind CSS gradients tool) to the body:

<!-- app/views/layouts/application.html.erb -->
<body class="bg-gradient-to-r from-cyan-500 to-blue-400">
  <main class="container mx-auto mt-28 px-5 flex">
    <%= yield %>
  </main>
</body>
Enter fullscreen mode Exit fullscreen mode

Next, let's update the pages#show structure to position the cards at the bottom of the screen:

<!-- app/views/pages/show.html.erb -->
<div class="fixed bottom-4 w-full max-w-sm">
  <ul class="relative grid gap-4">
    <%= render @cards %>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

Now, let's style the cards to make them look good. The cards should be stacked on top of each other when collapsed:

<!-- app/views/cards/_card.html.erb -->
<li class="bottom-0 w-full px-2 py-1 bg-white/80 backdrop-blur-sm border-0.5 border-white/70 ring ring-1 ring-offset-1 ring-black/10 rounded-lg shadow-lg hover:ring-black/20 transition-all duration-300 ease-in-out hover:scale-101 absolute">
  <%= link_to "#" do %>
    <h5 class="text-sm font-medium text-gray-800">
      <%= card.subject %>
    </h5>

    <p class="mt-0.5 text-sm font-light text-gray-600 text-base line-clamp-1">
      <%= card.description %>
    </p>
  <% end %>
</li>
Enter fullscreen mode Exit fullscreen mode

At this point, the cards look good individually, but they're all positioned absolutely in the same place. The next step is to use Rails' class_names helper to position them based on their order.

Using Rails' class_names helper with data variants

Now it's time to introduce the class_names helper to conditionally apply classes based on the card's position in the stack:

<!-- app/views/cards/_card.html.erb -->
<%= tag.li class: class_names(
  "bottom-0 w-full px-2 py-1 bg-white/80 backdrop-blur-sm border-0.5 border-white/70 ring ring-1 ring-offset-1 ring-black/10 rounded-lg shadow-lg hover:ring-black/20",
  "transition-all duration-300 ease-in-out hover:scale-101",
  "absolute",
  {
    "scale-100 translate-y-0": card_counter == 2,
    "scale-95 opacity-75 -translate-y-2": card_counter == 1,
    "scale-90 opacity-50 -translate-y-4": card_counter == 0,
  }) do %>
  <%= link_to card.url, class: "block" do %>
    <h5 class="text-sm font-medium text-gray-800">
      <%= card.subject %>
    </h5>

    <p class="mt-0.5 text-sm font-light text-gray-600 text-base line-clamp-1">
      <%= card.description %>
    </p>
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

This creates a stacked effect where each card is slightly smaller, more transparent, and positioned higher than the one below it. Oh, see the card_counter == * logic? It's a feature from Rails' partials. I explored it, along with many other features, in the article about Rails' Partial Features You (Didn't) Know.

Simple Stimulus Controller

Now let's add a Stimulus controller to handle the show/hide functionality:

// app/javascript/controllers/cards_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    open: { type: Boolean, default: false }
  }

  show() {
    this.openValue = true
  }

  hide() {
    this.openValue = false
  }
}
Enter fullscreen mode Exit fullscreen mode

Then update the HTML to use this controller:

<!-- app/views/pages/show.html.erb -->
<div class="fixed bottom-4 w-full max-w-sm">
  <ul
    data-controller="cards"
    data-cards-open-value="false"
    data-action="click->cards#show"
    class="relative grid gap-4 group/cards"
  >
    <%= render @cards %>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

Now update the card partial to use the group data variants:

<!-- app/views/cards/_card.html.erb -->
<%= tag.li class: class_names(
  "bottom-0 w-full px-2 py-1 bg-white/80 backdrop-blur-sm border-0.5 border-white/70 ring ring-1 ring-offset-1 ring-black/10 rounded-lg shadow-lg hover:ring-black/20",
  "transition-all duration-300 ease-in-out hover:scale-101",
  "group-data-[cards-open-value=false]/cards:absolute group-data-[cards-open-value=true]/cards:origin-bottom-right",
  {
    "group-data-[cards-open-value=false]/cards:scale-100 group-data-[cards-open-value=false]/cards:translate-y-0 group-data-[cards-open-value=true]/cards:rotate-0": card_counter == 2,
    "group-data-[cards-open-value=false]/cards:scale-95 group-data-[cards-open-value=false]/cards:opacity-75 group-data-[cards-open-value=false]/cards:-translate-y-2 group-data-[cards-open-value=true]/cards:translate-x-2 group-data-[cards-open-value=true]/cards:rotate-2": card_counter == 1,
    "group-data-[cards-open-value=false]/cards:scale-90 group-data-[cards-open-value=false]/cards:opacity-50 group-data-[cards-open-value=false]/cards:-translate-y-4 group-data-[cards-open-value=true]/cards:translate-x-4 group-data-[cards-open-value=true]/cards:rotate-4": card_counter == 0,
  }) do %>
  <%= link_to card.url do %>
    <h5 class="text-sm font-medium text-gray-800">
      <%= card.subject %>
    </h5>

    <p class="mt-0.5 text-sm font-light text-gray-600 text-base line-clamp-1">
      <%= card.description %>
    </p>
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

(that is a lot of classes 😅🙃)

Using Stimulus FX for the whenOutside Custom Action

To detect clicks outside our card stack, the Stimulus FX package needs to be installed. First, add it to the importmap ./bin/importmap pin stimulus-fx.

Then register it in the application:

// app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"
+ import { registerActionOptions } from "stimulus-fx";

const application = Application.start()
+ registerActionOptions(application);
Enter fullscreen mode Exit fullscreen mode

Stimulus FX is a package I built that provides additional action modifiers for Stimulus controllers. One of these modifiers is whenOutside, which checks if a click event occurred outside a specific element.

Update the HTML to use the whenOutside action modifier:

<!-- app/views/pages/show.html.erb -->
<div class="fixed bottom-4 w-full max-w-sm">
  <ul
    data-controller="cards"
    data-cards-open-value="false"
-    data-action="click->cards#show"
+    data-action="click->cards#show click@window->cards#hide:stop:whenOutside"
    class="relative grid gap-4 group/cards"
  >
    <%= render @cards %>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

Fixing a bug

There's a problem: when the cards are stacked (closed), clicking on a card should open the stack, but the cards are links, so clicking would navigate away. To solve this, the pointer-events-none Tailwind CSS utility class can be used:

- <%= link_to card.url class: "block" do %>
+ <%= link_to card.url, class: "block group-data-[cards-open-value=false]/cards:pointer-events-none" do %>
    <h5 class="text-sm font-medium text-gray-800">
      <%= card.subject %>
    </h5>

    <p class="mt-0.5 text-sm font-light text-gray-600 text-base line-clamp-1">
      <%= card.description %>
    </p>
  <% end %>
Enter fullscreen mode Exit fullscreen mode

The CSS pointer-events property specifies whether an element can be the target of mouse events. By setting it to none, the browser is instructed to “ignore” this element for mouse events and let them pass through to elements underneath. Cool, right? Previously you surely would have reached for JavaScript to solve this. 🤓

And there you have it. With just a bit of HTML, CSS, and a simple Stimulus controller, a beautiful macOS Dock-style fan UI has been created. Hopefully this example demonstrates how powerful modern (Tailwind-flavored) CSS can be as the entire UI effect is achieved mostly through CSS, with minimal JavaScript needed only for state management. ❤️

Top comments (1)

Collapse
 
railsdesigner profile image
Rails Designer

What feature did you (re)discover from this article?