Tile
Square image tile with a caption.
a pen plotter that draws in Bayer-dithered strokes.
Code
Copy into src/components/ph/ .
tile.css
/* Crossfade only, so it never reads as the image still loading. */
.ph-tile__desc {
position: absolute;
inset: 0;
overflow: auto;
margin: 0;
padding: 14px;
background: var(--background);
opacity: 0;
filter: blur(3px);
pointer-events: none;
transition:
opacity 100ms var(--ph-ease),
filter 100ms var(--ph-ease);
}
@media (hover: hover) and (pointer: fine) {
.ph-tile:is(:hover, :has(:focus-visible)) .ph-tile__desc {
opacity: 1;
filter: blur(0);
transition-duration: 160ms;
}
}
@media (hover: none) {
.ph-tile__desc {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ph-tile__desc {
filter: none;
transition: opacity 100ms linear;
}
}
Tile.astro
---
import Link from "./Link.astro";
import "./tile.css";
type Props = {
title: string;
href?: string;
meta?: string;
image?: string;
alt?: string;
description?: string;
size?: string;
class?: string;
};
const { title, href, meta, image, alt = title, description, size = "232px", class: className } = Astro.props;
const hasImage = image || Astro.slots.has("image");
---
<figure class:list={["ph-tile flex w-[var(--tile)] max-w-full flex-col gap-2", className]} style={`--tile: ${size}`}>
<div class="relative aspect-square w-full overflow-hidden border border-border">
{
hasImage ? (
<slot name="image">
<img class="size-full object-cover" src={image} alt={alt} loading="lazy" decoding="async" />
</slot>
) : (
<div class="dither-field size-full" />
)
}
{description && <p class="ph-tile__desc text-[13px] leading-[1.4]">{description}</p>}
</div>
<figcaption class="flex min-w-0 flex-col gap-0.5">
<span class="truncate leading-[1.35]">{href ? <Link href={href}>{title}</Link> : title}</span>
{meta && <span class="truncate text-[13px] leading-[1.35]">{meta}</span>}
</figcaption>
</figure>
Copy into components/ph/ (or src/components/ph/).
tile.css
/* Crossfade only, so it never reads as the image still loading. */
.ph-tile__desc {
position: absolute;
inset: 0;
overflow: auto;
margin: 0;
padding: 14px;
background: var(--background);
opacity: 0;
filter: blur(3px);
pointer-events: none;
transition:
opacity 100ms var(--ph-ease),
filter 100ms var(--ph-ease);
}
@media (hover: hover) and (pointer: fine) {
.ph-tile:is(:hover, :has(:focus-visible)) .ph-tile__desc {
opacity: 1;
filter: blur(0);
transition-duration: 160ms;
}
}
@media (hover: none) {
.ph-tile__desc {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ph-tile__desc {
filter: none;
transition: opacity 100ms linear;
}
}
tile.tsx
import type { CSSProperties, ReactNode } from "react";
import { cn } from "./cn";
import { Link } from "./link";
import "./tile.css";
type TileProps = {
title: string;
href?: string;
meta?: string;
image?: string | ReactNode;
alt?: string;
description?: string;
size?: string;
className?: string;
};
export function Tile({ title, href, meta, image, alt = title, description, size = "232px", className }: TileProps) {
return (
<figure className={cn("ph-tile flex w-[var(--tile)] max-w-full flex-col gap-2", className)} style={{ "--tile": size } as CSSProperties}>
<div className="relative aspect-square w-full overflow-hidden border border-border">
{typeof image === "string" ? (
<img className="size-full object-cover" src={image} alt={alt} loading="lazy" decoding="async" />
) : (
image ?? <div className="dither-field size-full" />
)}
{description && <p className="ph-tile__desc text-[13px] leading-[1.4]">{description}</p>}
</div>
<figcaption className="flex min-w-0 flex-col gap-0.5">
<span className="truncate leading-[1.35]">{href ? <Link href={href}>{title}</Link> : title}</span>
{meta && <span className="truncate text-[13px] leading-[1.35]">{meta}</span>}
</figcaption>
</figure>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");