empowered.house ↗

keyboard/isometric-keys.ex

Isometric keys

A few isometric keycaps on a deck, the pressed ones lowered and lime, for keyboard-first moments.

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

<div class="h-96 rounded-xl border border-border">
  <.keyboard_isometric_keys animated />
</div>
default
three-key chord · custom copy
two keys · hover
no caption · no deck · no glow
light surface · fade out

Props

Prop Type Default What it does
keys list [%{label: "Esc", pressed: false}, %{label: "Cmd", pressed: true}, ...] One to five `%{label, pressed}`, back to front. Labels up to five letters.
eyebrow string "Cmd + K"
caption any "Open the command menu" The line under the keys, up to 30 characters. `nil` hides it and the eyebrow.
deck boolean true The plate the keys stand on.
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.
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/keyboard/isometric-keys.ex
isometric-keys.ex
defmodule EHVisuals.Keyboard.IsometricKeys do
  @moduledoc """
  A few keycaps on a deck, drawn in isometric projection. The pressed keys
  sit lower and are lime.

  Plan coordinates: the keys run along x, each is 60 deep along y, and z is
  the height. `point/5` puts a plan point on screen; the label and the dish
  of a key are laid out flat in the plane of its top by one matrix.
  """

  use EHVisuals.Composition,
    name: "keyboard/isometric-keys",
    title: "Isometric keys",
    description:
      "A few isometric keycaps on a deck, the pressed ones lowered and lime, for keyboard-first moments."

  @depth 60
  @gap 12
  @margin 14
  @up 26
  @down 8
  @thickness 8
  @pad 16

  @keys [
    %{label: "Esc", pressed: false},
    %{label: "Cmd", pressed: true},
    %{label: "K", pressed: true}
  ]

  attr(:keys, :list,
    default: @keys,
    doc: "One to five `%{label, pressed}`, back to front. Labels up to five letters."
  )

  attr(:eyebrow, :string, default: "Cmd + K")

  attr(:caption, :any,
    default: "Open the command menu",
    doc: "The line under the keys, up to 30 characters. `nil` hides it and the eyebrow."
  )

  attr(:deck, :boolean, default: true, doc: "The plate the keys stand on.")
  attr(:label, :string, default: "Three isometric keycaps, Cmd and K pressed down")
  frame_attrs(isometric: false)

  @spec keyboard_isometric_keys(map()) :: Phoenix.LiveView.Rendered.t()
  def keyboard_isometric_keys(assigns) do
    ox = @pad + @depth + 2 * @margin
    oy = @pad + @up

    {keys, right} =
      assigns.keys
      |> Enum.take(5)
      |> Enum.with_index()
      |> Enum.map_reduce(0, fn {key, index}, x ->
        w = 60 + max(String.length(key.label) - 1, 0) * 13
        pressed = Map.get(key, :pressed, false)

        item = %{
          label: key.label,
          index: index,
          pressed: pressed,
          x: x,
          w: w,
          font: if(String.length(key.label) == 1, do: 20, else: 13),
          up: block(ox, oy, x, x + w, @up),
          down: block(ox, oy, x, x + w, @down)
        }

        {item, x + w + @gap}
      end)

    total = max(right - @gap, 60)
    front_x = ox + total - @depth
    front_y = oy + 0.5 * (total + @depth + 2 * @margin) + @thickness

    text_right =
      20 +
        max(
          String.length(assigns.caption || "") * 6.8,
          String.length(assigns.eyebrow) * 5.8
        )

    caption_top =
      if(text_right < front_x, do: front_y - 0.5 * (front_x - text_right), else: front_y) + 12

    height =
      if assigns.caption, do: max(front_y + @pad, caption_top + 41), else: front_y + @pad

    assigns =
      assigns
      |> assign(:items, keys)
      |> assign(:plate, plate(ox, oy, total))
      |> assign(:ox, ox)
      |> assign(:oy, oy)
      |> assign(:depth, @depth)
      |> assign(:heights, %{up: @up, down: @down})
      |> assign(:caption_top, Float.round(caption_top, 1))
      |> assign(:width, ox + total + 2 * @margin + @pad)
      |> assign(:height, Float.round(height, 1))

    ~H"""
    <.frame name="keyboard/isometric-keys" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 #{@width} #{@height}"}>
        <g :if={@deck} class="ehv-a-rise ehv-i-0">
          <polygon class="ehv-keyboard-isometric-keys__side" points={@plate.left} />
          <polygon class="ehv-keyboard-isometric-keys__side" points={@plate.right} />
          <polygon class="ehv-keyboard-isometric-keys__deck" points={@plate.top} />
        </g>
        <g :for={key <- @items} class={"ehv-a-drop ehv-i-#{key.index + 2}"}>
          <g :if={!key.pressed}>
            <polygon class="ehv-keyboard-isometric-keys__face" points={key.up.left} />
            <polygon
              class="ehv-keyboard-isometric-keys__face ehv-keyboard-isometric-keys__face--end"
              points={key.up.right}
            />
            <polygon class="ehv-keyboard-isometric-keys__top" points={key.up.top} />
            <g
              class="ehv-keyboard-isometric-keys__plane"
              transform={"matrix(1 0.5 -1 0.5 #{@ox} #{@oy - @heights.up})"}
            >
              <rect
                class="ehv-line-soft"
                x={key.x + 7}
                y="7"
                width={key.w - 14}
                height={@depth - 14}
                rx="7"
              />
              <text
                class="ehv-t-strong"
                x={key.x + key.w / 2}
                y={@depth / 2 + key.font * 0.35}
                font-size={key.font}
                text-anchor="middle"
              >
                {key.label}
              </text>
            </g>
          </g>
          <g :if={key.pressed} class="ehv-keyboard-isometric-keys__ghost">
            <polygon class="ehv-keyboard-isometric-keys__face" points={key.up.left} />
            <polygon
              class="ehv-keyboard-isometric-keys__face ehv-keyboard-isometric-keys__face--end"
              points={key.up.right}
            />
            <polygon class="ehv-keyboard-isometric-keys__top" points={key.up.top} />
          </g>
          <g :if={key.pressed} class="ehv-keyboard-isometric-keys__pressed">
            <polygon class="ehv-keyboard-isometric-keys__face--on" points={key.down.left} />
            <polygon class="ehv-keyboard-isometric-keys__shade" points={key.down.left} />
            <polygon class="ehv-keyboard-isometric-keys__face--on" points={key.down.right} />
            <polygon class="ehv-keyboard-isometric-keys__top--on" points={key.down.top} />
            <g
              class="ehv-keyboard-isometric-keys__plane"
              transform={"matrix(1 0.5 -1 0.5 #{@ox} #{@oy - @heights.down})"}
            >
              <rect
                class="ehv-keyboard-isometric-keys__dish--on"
                x={key.x + 7}
                y="7"
                width={key.w - 14}
                height={@depth - 14}
                rx="7"
              />
              <text
                class="ehv-t-strong ehv-t-on-lime"
                x={key.x + key.w / 2}
                y={@depth / 2 + key.font * 0.35}
                font-size={key.font}
                text-anchor="middle"
              >
                {key.label}
              </text>
            </g>
          </g>
        </g>
        <g :if={@caption} class="ehv-a-fade ehv-i-8">
          <text class="ehv-mono ehv-t-lime" x="20" y={@caption_top + 8} font-size="8.5">
            {String.upcase(@eyebrow)}
          </text>
          <text x="20" y={@caption_top + 27} font-size="13">{@caption}</text>
        </g>
      </svg>
    </.frame>
    """
  end

  # Plan {x, y, z} to screen: x runs down to the right, y down to the left.
  defp point(ox, oy, x, y, z), do: "#{ox + x - y},#{oy + 0.5 * (x + y) - z}"

  # A key from x0 to x1, `h` high: its top and the two faces towards the viewer.
  defp block(ox, oy, x0, x1, h) do
    p = fn x, y, z -> point(ox, oy, x, y, z) end

    %{
      top: Enum.join([p.(x0, 0, h), p.(x1, 0, h), p.(x1, @depth, h), p.(x0, @depth, h)], " "),
      left:
        Enum.join(
          [p.(x0, @depth, h), p.(x1, @depth, h), p.(x1, @depth, 0), p.(x0, @depth, 0)],
          " "
        ),
      right: Enum.join([p.(x1, 0, h), p.(x1, @depth, h), p.(x1, @depth, 0), p.(x1, 0, 0)], " ")
    }
  end

  # The deck: a thin plate reaching a margin past the keys on every side.
  defp plate(ox, oy, total) do
    p = fn x, y, z -> point(ox, oy, x, y, z) end
    {x0, x1, y0, y1, t} = {-@margin, total + @margin, -@margin, @depth + @margin, -@thickness}

    %{
      top: Enum.join([p.(x0, y0, 0), p.(x1, y0, 0), p.(x1, y1, 0), p.(x0, y1, 0)], " "),
      left: Enum.join([p.(x0, y1, 0), p.(x1, y1, 0), p.(x1, y1, t), p.(x0, y1, t)], " "),
      right: Enum.join([p.(x1, y0, 0), p.(x1, y1, 0), p.(x1, y1, t), p.(x1, y0, t)], " ")
    }
  end
end
assets/css/keyboard/isometric-keys.css
isometric-keys.css
/* keyboard/isometric-keys */
.ehv-keyboard-isometric-keys { --ehv-max-w: 560px; --ehv-step: 110ms; }

/* Hairlines stay one pixel wide in the skewed plane of a key top. */
.ehv-keyboard-isometric-keys__plane * { vector-effect: non-scaling-stroke; }

.ehv-keyboard-isometric-keys__deck { fill: var(--ehv-surface-1); stroke: var(--ehv-border-strong); stroke-width: 1; stroke-linejoin: round; }
.ehv-keyboard-isometric-keys__side { fill: var(--ehv-surface-2); stroke: var(--ehv-border-soft); stroke-width: 1; stroke-linejoin: round; }

.ehv-keyboard-isometric-keys__top { fill: var(--ehv-surface-3); stroke: var(--ehv-ink-3); stroke-width: 1; stroke-linejoin: round; }
.ehv-keyboard-isometric-keys__face { fill: var(--ehv-surface-2); stroke: var(--ehv-ink-3); stroke-width: 1; stroke-linejoin: round; }
.ehv-keyboard-isometric-keys__face--end { fill: var(--ehv-surface-1); }

/* A pressed key is one lime slab with dark edges; the long face is a shade darker. */
.ehv-keyboard-isometric-keys__top--on,
.ehv-keyboard-isometric-keys__face--on { fill: var(--ehv-lime-fill); stroke: var(--ehv-on-lime); stroke-width: 1; stroke-linejoin: round; }
.ehv-keyboard-isometric-keys__shade { fill: var(--ehv-on-lime); opacity: 0.22; }
.ehv-keyboard-isometric-keys__dish--on { fill: none; stroke: var(--ehv-on-lime); stroke-width: 1; opacity: 0.35; }

/* The press: each pressed key first stands at full height (the ghost), then
   the ghost goes and the lime slab comes down. Still, the ghost is hidden. */
@keyframes ehv-keyboard-isometric-keys-release { from { opacity: 1; } }
@keyframes ehv-keyboard-isometric-keys-press { from { opacity: 0; transform: translateY(-18px); } }

.ehv-keyboard-isometric-keys__ghost { opacity: 0; }
.ehv--animated .ehv-keyboard-isometric-keys__ghost {
  animation: ehv-keyboard-isometric-keys-release 180ms linear backwards;
  animation-delay: calc(var(--ehv-delay, 0ms) + var(--ehv-i, 0) * var(--ehv-step) + var(--ehv-dur) + 250ms);
}
.ehv--animated .ehv-keyboard-isometric-keys__pressed {
  animation: ehv-keyboard-isometric-keys-press 260ms var(--ehv-ease) backwards;
  animation-delay: calc(var(--ehv-delay, 0ms) + var(--ehv-i, 0) * var(--ehv-step) + var(--ehv-dur) + 250ms);
}

/* Hover lets the pressed keys come up a little. */
.ehv--hover .ehv-keyboard-isometric-keys__pressed { transition: transform 220ms var(--ehv-ease); }
.ehv--hover:hover .ehv-keyboard-isometric-keys__pressed { transform: translateY(-5px); }