keyboard/isometric-keys.ex
Isometric keys
A few isometric keycaps on a deck, the pressed ones lowered and lime, for keyboard-first moments.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.keyboard_isometric_keys animated />
</div>
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
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
/* 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); }