empowered.house ↗

notifications/toast.ex

Toast

A toast that has slid in at the corner of a faint page outline, with a title, one line and an action.

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

<div class="h-96 rounded-xl border border-border">
  <.notifications_toast animated />
</div>
default
isometric
something failed · top right
bottom left · hover · no glow
light surface
no action · isometric · fade out · still

Props

Prop Type Default What it does
title string "Invoice posted" Up to 24 characters.
text string "INV-2291 went to the ERP." One line, up to 30 characters.
action any "Undo" The button, or `nil` for none.
corner "top-left" | "top-right" | "bottom-left" | "bottom-right" "bottom-right" The corner of the page the toast sits in. It slides in from that side.
tone "ok" | "risk" "ok" `ok` is a lime tick, `risk` a coral warning.
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/notifications/toast.ex
toast.ex
defmodule EHVisuals.Notifications.Toast do
  @moduledoc """
  A toast notification that has slid in at the corner of a page: a title, one
  line and an action, over a faint outline of the page behind it.
  """

  use EHVisuals.Composition,
    name: "notifications/toast",
    title: "Toast",
    description:
      "A toast that has slid in at the corner of a faint page outline, with a title, one line and an action."

  @toast_w 236
  @margin 14

  attr(:title, :string, default: "Invoice posted", doc: "Up to 24 characters.")

  attr(:text, :string,
    default: "INV-2291 went to the ERP.",
    doc: "One line, up to 30 characters."
  )

  attr(:action, :any, default: "Undo", doc: "The button, or `nil` for none.")

  attr(:corner, :string,
    default: "bottom-right",
    values: ~w(top-left top-right bottom-left bottom-right),
    doc: "The corner of the page the toast sits in. It slides in from that side."
  )

  attr(:tone, :string,
    default: "ok",
    values: ~w(ok risk),
    doc: "`ok` is a lime tick, `risk` a coral warning."
  )

  attr(:label, :string,
    default: "A toast in the bottom right corner of a page saying an invoice was posted"
  )

  frame_attrs()

  @spec notifications_toast(map()) :: Phoenix.LiveView.Rendered.t()
  def notifications_toast(assigns) do
    [vertical, side] = String.split(assigns.corner, "-")
    height = if assigns.action, do: 88, else: 64

    assigns =
      assigns
      |> assign(:side, side)
      |> assign(:x, if(side == "left", do: @margin, else: 400 - @margin - @toast_w))
      |> assign(:y, if(vertical == "top", do: 34 + @margin, else: 260 - @margin - height))
      |> assign(:toast_h, height)
      |> assign(:action_w, 22 + String.length(assigns.action || "") * 6)

    ~H"""
    <.frame name="notifications/toast" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 400 260">
        <g class="ehv-a-fade">
          <rect
            class="ehv-notifications-toast__page"
            x="0.5"
            y="0.5"
            width="399"
            height="259"
            rx="12"
          />
          <line class="ehv-line-faint" x1="1" x2="399" y1="34.5" y2="34.5" />
          <circle :for={x <- [18, 31, 44]} class="ehv-line-soft" cx={x} cy="17.5" r="3.5" />
          <rect class="ehv-fill-soft" x="140" y="11.5" width="120" height="12" rx="6" />
          <rect class="ehv-fill-soft" x="24" y="54" width="112" height="10" rx="5" />
          <rect class="ehv-fill-soft" x="24" y="72" width="196" height="6" rx="3" />
          <rect
            :for={x <- [24, 145, 266]}
            class="ehv-line-faint"
            x={x + 0.5}
            y="96.5"
            width="110"
            height="60"
            rx="8"
          />
          <rect
            :for={x <- [24, 145, 266]}
            class="ehv-fill-soft"
            x={x + 12}
            y="110"
            width="44"
            height="6"
            rx="3"
          />
          <rect
            :for={x <- [24, 145, 266]}
            class="ehv-fill-soft"
            x={x + 12}
            y="126"
            width="70"
            height="12"
            rx="4"
          />
          <g :for={y <- [180, 202, 224]}>
            <line class="ehv-line-faint" x1="24" x2="376" y1={y + 0.5} y2={y + 0.5} />
            <rect class="ehv-fill-soft" x="24" y={y + 8} width="64" height="6" rx="3" />
            <rect class="ehv-fill-soft" x="120" y={y + 8} width="110" height="6" rx="3" />
          </g>
        </g>
        <g class={"ehv-notifications-toast__in ehv-notifications-toast__in--#{@side} ehv-i-4"}>
          <g class="ehv-h-lift">
            <rect
              class={[
                "ehv-notifications-toast__card",
                @tone == "risk" && "ehv-notifications-toast__card--risk"
              ]}
              x={@x + 0.5}
              y={@y + 0.5}
              width="235"
              height={@toast_h - 1}
              rx="11"
            />
            <g :if={@tone == "ok"}>
              <circle class="ehv-fill-lime-soft" cx={@x + 25} cy={@y + 27} r="11" />
              <path
                class="ehv-line-lime ehv-round ehv-a-draw ehv-i-8"
                d={"M#{@x + 20} #{@y + 27.2} l3.4 3.4 l6.6 -7.4"}
                pathLength="1"
              />
            </g>
            <g :if={@tone == "risk"}>
              <circle class="ehv-fill-coral-soft" cx={@x + 25} cy={@y + 27} r="11" />
              <path
                class="ehv-line-coral ehv-round"
                d={"M#{@x + 25} #{@y + 21.5} v6.5 M#{@x + 25} #{@y + 32} v0.4"}
              />
            </g>
            <text class="ehv-t-strong" x={@x + 46} y={@y + 25} font-size="12.5">{@title}</text>
            <text class="ehv-t-2" x={@x + 46} y={@y + 42} font-size="11.5">{@text}</text>
            <path
              class="ehv-line ehv-round"
              d={"M#{@x + 214} #{@y + 16} l7 7 M#{@x + 221} #{@y + 16} l-7 7"}
            />
            <g :if={@action}>
              <rect
                class="ehv-notifications-toast__action"
                x={@x + 46.5}
                y={@y + 52.5}
                width={@action_w - 1}
                height="21"
                rx="6"
              />
              <text
                class={["ehv-t-strong", if(@tone == "risk", do: "ehv-t-coral", else: "ehv-t-lime")]}
                x={@x + 46 + @action_w / 2}
                y={@y + 67}
                font-size="10.5"
                text-anchor="middle"
              >
                {@action}
              </text>
            </g>
            <rect
              class="ehv-fill-soft"
              x={@x + 12}
              y={@y + @toast_h - 6}
              width="212"
              height="2"
              rx="1"
            />
            <rect
              class={[
                "ehv-notifications-toast__timer",
                if(@tone == "risk", do: "ehv-fill-coral", else: "ehv-fill-lime")
              ]}
              x={@x + 12}
              y={@y + @toast_h - 6}
              width="127.2"
              height="2"
              rx="1"
            />
          </g>
        </g>
      </svg>
    </.frame>
    """
  end
end
assets/css/notifications/toast.css
toast.css
/* notifications/toast */
.ehv-notifications-toast { --ehv-max-w: 440px; }

/* The page behind is only an outline: it must not compete with the toast. */
.ehv-notifications-toast__page { fill: var(--ehv-surface-1); fill-opacity: 0.5; stroke: var(--ehv-border-soft); stroke-width: 1; }

.ehv-notifications-toast__card { fill: var(--ehv-surface-2); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-notifications-toast__card--risk { stroke: var(--ehv-coral); }
.ehv-notifications-toast__action { fill: var(--ehv-surface-3); stroke: var(--ehv-border-soft); stroke-width: 1; }

/* The toast slides in from the side of the page it sits on. */
@keyframes ehv-notifications-toast-in-right { from { opacity: 0; transform: translateX(32px); } }
@keyframes ehv-notifications-toast-in-left { from { opacity: 0; transform: translateX(-32px); } }

.ehv--animated .ehv-notifications-toast__in {
  animation-duration: var(--ehv-dur);
  animation-timing-function: var(--ehv-ease);
  animation-fill-mode: backwards;
  animation-delay: calc(var(--ehv-delay, 0ms) + var(--ehv-i, 0) * var(--ehv-step));
}
.ehv--animated .ehv-notifications-toast__in--right { animation-name: ehv-notifications-toast-in-right; }
.ehv--animated .ehv-notifications-toast__in--left { animation-name: ehv-notifications-toast-in-left; }

/* The time left before the toast leaves: the bar runs down from full to where it rests. */
@keyframes ehv-notifications-toast-timer { from { transform: scaleX(1.6667); } }

.ehv-notifications-toast__timer { transform-box: fill-box; transform-origin: left; }
.ehv--animated .ehv-notifications-toast__timer {
  animation: ehv-notifications-toast-timer 2.8s linear backwards;
  animation-delay: calc(var(--ehv-delay, 0ms) + 4 * var(--ehv-step) + var(--ehv-dur));
}

.ehv--hover .ehv-notifications-toast__action { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-notifications-toast__action { stroke: var(--ehv-ink-2); }