empowered.house ↗

states/not-found.ex

Not found state

A not-found state: a route line running into a barrier in front of a large 404.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.states_not_found animated />
</div>
default
isometric
three stops · custom copy
another code · one stop · hover
light surface
isometric · fade out · still

Props

Prop Type Default What it does
code string "404" Three characters.
route list ["app", "orders"] One to three parts of the address that do exist, six characters at most.
title string "This page does not exist" 40 characters at most.
text string "Nothing lives at /orders/2291. It may have been " <> ... 62 characters at most. Empty hides it.
action string "Back to orders" The way out. Empty hides it.
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/not-found.ex
not-found.ex
defmodule EHVisuals.States.NotFound do
  @moduledoc """
  A not-found state: a route runs through the parts of the address that
  exist and stops at a barrier in front of a large status code.
  """

  use EHVisuals.Composition,
    name: "states/not-found",
    title: "Not found state",
    description: "A not-found state: a route line running into a barrier in front of a large 404."

  attr(:code, :string, default: "404", doc: "Three characters.")

  attr(:route, :list,
    default: ~w(app orders),
    doc: "One to three parts of the address that do exist, six characters at most."
  )

  attr(:title, :string, default: "This page does not exist", doc: "40 characters at most.")

  attr(:text, :string,
    default: "Nothing lives at /orders/2291. It may have been archived.",
    doc: "62 characters at most. Empty hides it."
  )

  attr(:action, :string, default: "Back to orders", doc: "The way out. Empty hides it.")

  attr(:label, :string, default: "A route line stopped by a barrier in front of a large 404")

  frame_attrs()

  @spec states_not_found(map()) :: Phoenix.LiveView.Rendered.t()
  def states_not_found(assigns) do
    names = Enum.take(assigns.route, 3)
    count = length(names)
    gap = if count > 1, do: 64 / (count - 1), else: 0

    stops =
      for {name, index} <- Enum.with_index(names) do
        %{
          name: name,
          index: index,
          x: Float.round(if(count > 1, do: 28 + index * gap, else: 60.0), 1),
          # With three stops the labels alternate, so they cannot collide.
          label_y: if(count == 3 and rem(index, 2) == 1, do: 62, else: 95)
        }
      end

    text_y = if assigns.text == "", do: 166, else: 186
    action_y = text_y + 14

    assigns =
      assigns
      |> assign(:stops, stops)
      |> assign(:stripes, for(index <- 0..4, do: stripe(34 + index * 16)))
      |> assign(:text_y, text_y)
      |> assign(:action_y, action_y)
      |> assign(:action_w, Float.round(38 + String.length(assigns.action) * 5.9, 1))
      |> assign(:height, if(assigns.action == "", do: text_y + 18, else: action_y + 26 + 12))

    ~H"""
    <.frame name="states/not-found" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 400 #{@height}"}>
        <g class="ehv-h-lift">
          <text
            class="ehv-t-display ehv-states-not-found__code ehv-a-fade"
            x="388"
            y="119"
            font-size="128"
            text-anchor="end"
          >
            {@code}
          </text>

          <line
            class="ehv-line-lime ehv-a-draw ehv-i-2"
            x1="12"
            x2="120"
            y1="74"
            y2="74"
            pathLength="1"
          />
          <line class="ehv-line-lime ehv-a-fade ehv-i-2" x1="12" x2="12" y1="69" y2="79" />
          <g :for={stop <- @stops} class={"ehv-a-pop ehv-i-#{stop.index + 3}"}>
            <circle class="ehv-node-lime" cx={stop.x} cy="74" r="4.5" />
          </g>
          <text
            :for={stop <- @stops}
            class={"ehv-mono ehv-t-2 ehv-a-fade ehv-i-#{stop.index + 3}"}
            x={stop.x}
            y={stop.label_y}
            font-size="7.5"
            text-anchor="middle"
          >
            {String.upcase(stop.name)}
          </text>
          <circle class="ehv-fill-lime ehv-states-not-found__packet" cx="106" cy="74" r="3" />
          <g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-8">
            <circle class="ehv-fill-lime" cy="74" r="3">
              <animate attributeName="cx" from="12" to="116" dur="2.2s" repeatCount="indefinite" />
              <animate
                attributeName="opacity"
                values="0;1;1;0"
                keyTimes="0;0.12;0.88;1"
                dur="2.2s"
                repeatCount="indefinite"
              />
            </circle>
          </g>

          <g class="ehv-a-drop ehv-i-6">
            <rect class="ehv-states-not-found__bar" x="120.5" y="34.5" width="13" height="79" rx="2" />
            <polygon :for={points <- @stripes} class="ehv-fill-coral" points={points} />
            <line class="ehv-line-coral ehv-round" x1="113" x2="141" y1="117.5" y2="117.5" />
          </g>

          <text class="ehv-t-strong ehv-a-fade ehv-i-8" x="12" y="166" font-size="15">{@title}</text>
          <text :if={@text != ""} class="ehv-t-2 ehv-a-fade ehv-i-9" x="12" y="186" font-size="12">
            {@text}
          </text>
          <g :if={@action != ""} class="ehv-a-fade ehv-i-10">
            <rect
              class="ehv-card-2 ehv-states-not-found__action"
              x="12.5"
              y={@action_y + 0.5}
              width={@action_w}
              height="25"
              rx="7"
            />
            <path
              class="ehv-line-ink ehv-round"
              d={"M33 #{@action_y + 13} h-10 M27 #{@action_y + 9} l-4 4 l4 4"}
            />
            <text class="ehv-t-strong" x="41" y={@action_y + 17} font-size="11">{@action}</text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # One diagonal stripe of the barrier, cut straight by its two long edges.
  defp stripe(y), do: "121,#{y + 8} 133,#{y} 133,#{y + 8} 121,#{y + 16}"
end
assets/css/states/not-found.css
not-found.css
/* states/not-found */
.ehv-states-not-found { --ehv-max-w: 440px; }

/* The status code is the backdrop: outlined, in the tone of a surface. */
.ehv svg .ehv-states-not-found__code { fill: var(--ehv-surface-3); stroke: var(--ehv-ink-4); stroke-width: 1; }

.ehv-states-not-found__bar { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1; }

/* The still packet stands in for the travelling one when nothing moves. */
.ehv--animated .ehv-states-not-found__packet { display: none; }
@media (prefers-reduced-motion: reduce) {
  .ehv--animated .ehv-states-not-found__packet { display: inline; }
}

.ehv--hover .ehv-states-not-found__action { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-states-not-found__action { stroke: var(--ehv-ink-2); }