states/empty.ex
Empty state
An empty state: a grid of dashed placeholder slots with one add action in the middle.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.states_empty animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Add your first workflow" |
34 characters at most. |
text |
string | "Nothing runs here yet. Start with one process." |
The line under the title, 54 characters at most. Empty hides it. |
hint |
string | "Or pick one of 12 templates" |
A mono line at the bottom, 44 characters at most. Empty hides it. |
columns |
integer | 5 |
Placeholder columns, 3 to 7. |
rows |
integer | 3 |
Placeholder rows, 1 to 4. |
Frame props, shared by every composition
| Prop | Type | Default | What it does |
|---|---|---|---|
animated |
boolean | false |
Turns motion on. Without it the composition renders in its final, still state. |
trigger |
"mount" | "in-view" | "in-view-repeat" | "in-view" |
When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view. |
hover |
boolean | false |
Enables the hover micro-interactions the composition supports. |
glow |
boolean | true |
The warm incandescent halo behind the composition. |
fade_out |
boolean | false |
Fades the bottom edge into the surface underneath. |
isometric |
boolean | false |
Tilts the composition into the isometric plane of the platform stack. |
theme |
"dark" | "light" | "dark" |
`dark` for the site canvas, `light` for the cream reversed surface. |
class |
string | nil |
Extra classes for the frame. |
rest |
global | — |
Any other HTML attribute, set on the frame. |
Source
Two files. Copy them into your app if you would rather own the code than depend on the library.
lib/eh_visuals/states/empty.ex
defmodule EHVisuals.States.Empty do
@moduledoc """
An empty state: a grid of dashed placeholder slots with the add action in
the middle one, and a line that says what to do first.
"""
use EHVisuals.Composition,
name: "states/empty",
title: "Empty state",
description:
"An empty state: a grid of dashed placeholder slots with one add action in the middle."
attr(:title, :string, default: "Add your first workflow", doc: "34 characters at most.")
attr(:text, :string,
default: "Nothing runs here yet. Start with one process.",
doc: "The line under the title, 54 characters at most. Empty hides it."
)
attr(:hint, :string,
default: "Or pick one of 12 templates",
doc: "A mono line at the bottom, 44 characters at most. Empty hides it."
)
attr(:columns, :integer, default: 5, doc: "Placeholder columns, 3 to 7.")
attr(:rows, :integer, default: 3, doc: "Placeholder rows, 1 to 4.")
attr(:label, :string,
default: "An empty grid of placeholder slots with an add button in the middle"
)
frame_attrs()
@spec states_empty(map()) :: Phoenix.LiveView.Rendered.t()
def states_empty(assigns) do
columns = assigns.columns |> max(3) |> min(7)
rows = assigns.rows |> max(1) |> min(4)
cell_w = Float.round((320 - (columns - 1) * 10) / columns, 1)
add = {div(columns, 2), div(rows - 1, 2)}
slots =
for row <- 0..(rows - 1), column <- 0..(columns - 1), {column, row} != add do
%{
x: Float.round(20 + column * (cell_w + 10), 1),
y: 20 + row * 48,
index: column + row + 1
}
end
{add_column, add_row} = add
title_y = 20 + rows * 48 - 10 + 34
text_y = if assigns.text == "", do: title_y, else: title_y + 20
hint_y = if assigns.hint == "", do: text_y, else: text_y + 22
assigns =
assigns
|> assign(:slots, slots)
|> assign(:cell_w, cell_w)
|> assign(:add_x, Float.round(20 + add_column * (cell_w + 10), 1))
|> assign(:add_y, 20 + add_row * 48)
|> assign(:title_y, title_y)
|> assign(:text_y, text_y)
|> assign(:hint_y, hint_y)
|> assign(:height, hint_y + 24)
~H"""
<.frame name="states/empty" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 360 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height={@height - 1} rx="12" />
<rect
:for={slot <- @slots}
class={"ehv-states-empty__slot ehv-a-fade ehv-i-#{slot.index}"}
x={slot.x}
y={slot.y}
width={@cell_w}
height="38"
rx="8"
/>
<circle
class="ehv-fill-lime ehv-a-ping ehv-i-8"
cx={@add_x + @cell_w / 2}
cy={@add_y + 19}
r="9"
/>
<g class="ehv-a-pop ehv-i-6">
<rect
class="ehv-states-empty__add"
x={@add_x}
y={@add_y}
width={@cell_w}
height="38"
rx="8"
/>
<path
class="ehv-line-lime ehv-round ehv-states-empty__plus"
d={"M#{@add_x + @cell_w / 2 - 5.5} #{@add_y + 19}h11 M#{@add_x + @cell_w / 2} #{@add_y + 13.5}v11"}
/>
</g>
<text
class="ehv-t-strong ehv-a-fade ehv-i-8"
x="180"
y={@title_y}
font-size="15"
text-anchor="middle"
>
{@title}
</text>
<text
:if={@text != ""}
class="ehv-t-2 ehv-a-fade ehv-i-9"
x="180"
y={@text_y}
font-size="11.5"
text-anchor="middle"
>
{@text}
</text>
<text
:if={@hint != ""}
class="ehv-mono ehv-a-fade ehv-i-10"
x="180"
y={@hint_y}
font-size="8"
text-anchor="middle"
>
{String.upcase(@hint)}
</text>
</g>
</svg>
</.frame>
"""
end
end
assets/css/states/empty.css
/* states/empty */
.ehv-states-empty { --ehv-max-w: 400px; --ehv-step: 70ms; }
.ehv-states-empty__slot { fill: none; stroke: var(--ehv-border-strong); stroke-width: 1; stroke-dasharray: 3 4; }
.ehv-states-empty__add { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-width: 1.25; }
.ehv .ehv-states-empty__plus { stroke-width: 1.5; }
.ehv--hover .ehv-states-empty__add { transition: fill 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-states-empty__add { fill: var(--ehv-lime-fill); }
.ehv--hover .ehv-states-empty__plus { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-states-empty__plus { stroke: var(--ehv-on-lime); }