Event card
Date, time, location and category. Add an image for the list layout.
Code
Copy into src/components/ph/ .
event-card.css
.ph-event-card {
transition:
border-color 150ms cubic-bezier(0.23, 1, 0.32, 1),
transform 150ms cubic-bezier(0.23, 1, 0.32, 1);
}
.ph-event-card:active {
transform: scale(0.99);
}
.ph-event-card__star {
transition: transform 150ms;
}
@media (hover: hover) {
.ph-event-card:hover .ph-event-card__star {
animation: ph-star-idle 0.75s steps(1, end) 0.3s infinite;
transform: scale(1.15) rotate(-90deg);
}
}
@keyframes ph-star-idle {
0% {
transform: scale(0.9) rotate(20deg);
}
50% {
transform: scale(1) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ph-event-card,
.ph-event-card__star {
transition: none;
animation: none !important;
}
}
categories.ts
export const categoryColors: Record<string, string> = {
"hack-night": "bg-ph-yellow text-ph-black",
workshop: "bg-ph-pink text-ph-black",
show: "bg-ph-orange text-ph-black",
other: "bg-ph-purple text-ph-cream",
};
export const categorySlug = (category: string) => category.trim().toLowerCase().replace(/\s+/g, "-");
export const categoryClasses = (category: string) => categoryColors[categorySlug(category)] ?? categoryColors.other;
EventCard.astro
---
import Icon from "./Icon.astro";
import { categoryClasses, categorySlug } from "./categories";
import "./event-card.css";
type Props = {
title: string;
href: string;
date: string;
time?: string;
location?: string;
category?: string;
image?: string;
class?: string;
};
const { title, href, date, time, location, category, image, class: className } = Astro.props;
const when = [date, time].filter(Boolean).join(" • ");
---
<a
href={href}
data-category={category && categorySlug(category)}
class:list={[
"ph-event-card group flex w-full gap-4 border border-border bg-card text-left text-card-foreground no-underline hover:border-foreground sm:gap-6",
image ? "flex-row items-stretch p-3 sm:p-5" : "h-full flex-col justify-between px-6 py-5",
className,
]}
>
{
image && (
<div class="size-24 shrink-0 bg-muted">
<img class="size-full object-cover" src={image} alt="" width="96" height="96" loading="lazy" decoding="async" />
</div>
)
}
<div class="flex min-w-0 flex-1 flex-col justify-between gap-2">
<div class="flex items-start justify-between gap-3">
<p class="font-mono text-[15px] leading-none font-semibold uppercase">{when}</p>
<Icon name="star" class="ph-event-card__star size-3.5" />
</div>
<h3 class="text-lg leading-tight font-semibold">{title}</h3>
<div class="flex flex-wrap items-center justify-between gap-2">
{
location && (
<p class="flex min-w-0 items-center gap-2 font-mono text-[15px] leading-none font-semibold">
<Icon name="location-pin" class="size-3.5" />
<span class="line-clamp-1">{location}</span>
</p>
)
}
{
category && (
<span class:list={["px-1.5 py-0.5 font-mono text-[11px] leading-none font-semibold tracking-wide uppercase", categoryClasses(category)]}>
{category}
</span>
)
}
</div>
</div>
</a>
Copy into components/ph/ (or src/components/ph/).
event-card.css
.ph-event-card {
transition:
border-color 150ms cubic-bezier(0.23, 1, 0.32, 1),
transform 150ms cubic-bezier(0.23, 1, 0.32, 1);
}
.ph-event-card:active {
transform: scale(0.99);
}
.ph-event-card__star {
transition: transform 150ms;
}
@media (hover: hover) {
.ph-event-card:hover .ph-event-card__star {
animation: ph-star-idle 0.75s steps(1, end) 0.3s infinite;
transform: scale(1.15) rotate(-90deg);
}
}
@keyframes ph-star-idle {
0% {
transform: scale(0.9) rotate(20deg);
}
50% {
transform: scale(1) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ph-event-card,
.ph-event-card__star {
transition: none;
animation: none !important;
}
}
categories.ts
export const categoryColors: Record<string, string> = {
"hack-night": "bg-ph-yellow text-ph-black",
workshop: "bg-ph-pink text-ph-black",
show: "bg-ph-orange text-ph-black",
other: "bg-ph-purple text-ph-cream",
};
export const categorySlug = (category: string) => category.trim().toLowerCase().replace(/\s+/g, "-");
export const categoryClasses = (category: string) => categoryColors[categorySlug(category)] ?? categoryColors.other;
event-card.tsx
import { categoryClasses, categorySlug } from "./categories";
import { cn } from "./cn";
import { Icon } from "./icon";
import "./event-card.css";
type EventCardProps = {
title: string;
href: string;
date: string;
time?: string;
location?: string;
category?: string;
image?: string;
className?: string;
};
export function EventCard({ title, href, date, time, location, category, image, className }: EventCardProps) {
const when = [date, time].filter(Boolean).join(" • ");
return (
<a
href={href}
data-category={category ? categorySlug(category) : undefined}
className={cn(
"ph-event-card group flex w-full gap-4 border border-border bg-card text-left text-card-foreground no-underline hover:border-foreground sm:gap-6",
image ? "flex-row items-stretch p-3 sm:p-5" : "h-full flex-col justify-between px-6 py-5",
className,
)}
>
{image && (
<div className="size-24 shrink-0 bg-muted">
<img className="size-full object-cover" src={image} alt="" width={96} height={96} loading="lazy" decoding="async" />
</div>
)}
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2">
<div className="flex items-start justify-between gap-3">
<p className="font-mono text-[15px] leading-none font-semibold uppercase">{when}</p>
<Icon name="star" className="ph-event-card__star size-3.5" />
</div>
<h3 className="text-lg leading-tight font-semibold">{title}</h3>
<div className="flex flex-wrap items-center justify-between gap-2">
{location && (
<p className="flex min-w-0 items-center gap-2 font-mono text-[15px] leading-none font-semibold">
<Icon name="location-pin" className="size-3.5" />
<span className="line-clamp-1">{location}</span>
</p>
)}
{category && (
<span className={cn("px-1.5 py-0.5 font-mono text-[11px] leading-none font-semibold tracking-wide uppercase", categoryClasses(category))}>
{category}
</span>
)}
</div>
</div>
</a>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");