Split Contact Grid
ContactEditorial-style contact page: underline-style form inputs, a three-column info grid with copy-to-clipboard buttons, and a dot-grid 'find us online' band.
Create a Svelte 5 (runes) contact section styled like a minimal editorial page, bordered left/right on desktop (`lg:border-x`), max-w-screen-xl. Header: large bold heading + short subtext, generous top padding. Form: 2-column grid (name / email / full-width subject / full-width message), each field styled as an underline-only input (no border box, just `border-b`, transparent background, monospace text) with an uppercase tracked-wide label above it. Submit button is a solid primary pill with a small send icon that's part of the button, not separate. Below the form, a 3-column grid of bordered boxes (Email / Office / Phone), each with a header row (icon + uppercase title) on a muted background, a centered value area (the email/address/phone numbers, each phone/email with a small copy-to-clipboard icon button that swaps to a checkmark for 1.5s on click via the Clipboard API), and a footer row with a short description. Final band: a 'find us online' heading over a radial dot-grid background (CSS radial-gradient background-image with a radial-gradient mask fading the dots out toward the edges), with a wrapped row of pill-shaped social buttons (icon + label) that open in a new tab. All copy is data-driven via a single `content` prop: `{ heading, subtext, form: {action, fields:{name,email,subject,message}, submit_label}, email:{title,description,address}, office:{title,description,address}, phone:{title,description,numbers}, social:{heading,links:{platform,href,label}[]} }`.
<script lang="ts">
import { cn } from '$lib/utils';
import CheckIcon from '@lucide/svelte/icons/check';
import CopyIcon from '@lucide/svelte/icons/copy';
import MailIcon from '@lucide/svelte/icons/mail';
import MapPinIcon from '@lucide/svelte/icons/map-pin';
import PhoneIcon from '@lucide/svelte/icons/phone';
import SendIcon from '@lucide/svelte/icons/send';
type SocialLink = { platform: string; href: string; label: string };
type FormField = { label: string; placeholder: string };
type Props = {
content: {
heading: string;
subtext: string;
form: {
action: string;
fields: { name: FormField; email: FormField; subject: FormField; message: FormField };
submit_label: string;
};
email: { title: string; description: string; address: string };
office: { title: string; description: string; address: string };
phone: { title: string; description: string; numbers: string[] };
social: { heading: string; links: SocialLink[] };
};
};
let { content }: Props = $props();
// Inline SVG paths for brand icons (Lucide removed brand icons from its library)
const socialPaths: Record<string, string> = {
github: `<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>`,
twitter: `<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>`,
x: `<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>`,
linkedin: `<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>`,
instagram: `<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>`,
facebook: `<path fill-rule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clip-rule="evenodd"/>`,
youtube: `<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>`,
};
let copied = $state<string | null>(null);
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
copied = text;
setTimeout(() => {
copied = null;
}, 1500);
} catch {
// clipboard API unavailable
}
}
</script>
{#snippet copyBtn(text: string)}
<button
type="button"
class="relative inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none"
onclick={() => copyText(text)}
disabled={copied === text}
aria-label={copied === text ? 'Copied' : 'Copy to clipboard'}
>
<span
class={cn(
'absolute inset-0 flex items-center justify-center transition-all duration-200',
copied === text ? 'scale-100 opacity-100' : 'scale-0 opacity-0'
)}
>
<CheckIcon class="size-3.5 stroke-emerald-500" aria-hidden="true" />
</span>
<span
class={cn(
'absolute inset-0 flex items-center justify-center transition-all duration-200',
copied === text ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
)}
>
<CopyIcon class="size-3.5" aria-hidden="true" />
</span>
</button>
{/snippet}
<section class="relative w-full bg-background">
<!-- Decorative background gradient -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 isolate -z-10 overflow-hidden">
<div
class="absolute -top-48 left-1/4 h-[400px] w-[640px] -rotate-12 rounded-full opacity-50"
style="background: radial-gradient(68.54% 68.72% at 55.02% 31.46%, color-mix(in oklab, var(--foreground) 6%, transparent) 0%, rgba(128,128,128,0.02) 50%, color-mix(in oklab, var(--foreground) 1%, transparent) 80%)"
></div>
<div
class="absolute -top-24 left-1/3 h-[280px] w-[200px] -rotate-12 rounded-full opacity-40"
style="background: radial-gradient(50% 50% at 50% 50%, color-mix(in oklab, var(--foreground) 4%, transparent) 0%, color-mix(in oklab, var(--foreground) 1%, transparent) 80%, transparent 100%)"
></div>
</div>
<div class="mx-auto h-full max-w-screen-xl lg:border-x">
<!-- Header -->
<div class="px-4 pt-24 pb-10 lg:px-6">
<h1 class="text-4xl font-bold tracking-tight md:text-5xl">{content.heading}</h1>
<p class="mt-3 max-w-xl text-base text-muted-foreground">{content.subtext}</p>
</div>
<!-- Contact form -->
<form action={content.form.action} class="px-4 pb-16 lg:px-6">
<div class="grid gap-x-8 gap-y-8 sm:grid-cols-2">
<div class="space-y-2">
<label for="cf2-name" class="block text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{content.form.fields.name.label}
</label>
<input
id="cf2-name"
type="text"
name="name"
class="w-full border-b border-border bg-transparent pb-2.5 font-mono text-sm tracking-wide text-foreground placeholder:text-muted-foreground/40 focus:border-foreground focus:outline-none"
placeholder={content.form.fields.name.placeholder}
required
/>
</div>
<div class="space-y-2">
<label for="cf2-email" class="block text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{content.form.fields.email.label}
</label>
<input
id="cf2-email"
type="email"
name="email"
class="w-full border-b border-border bg-transparent pb-2.5 font-mono text-sm tracking-wide text-foreground placeholder:text-muted-foreground/40 focus:border-foreground focus:outline-none"
placeholder={content.form.fields.email.placeholder}
required
/>
</div>
<div class="space-y-2 sm:col-span-2">
<label for="cf2-subject" class="block text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{content.form.fields.subject.label}
</label>
<input
id="cf2-subject"
type="text"
name="subject"
class="w-full border-b border-border bg-transparent pb-2.5 font-mono text-sm tracking-wide text-foreground placeholder:text-muted-foreground/40 focus:border-foreground focus:outline-none"
placeholder={content.form.fields.subject.placeholder}
/>
</div>
<div class="space-y-2 sm:col-span-2">
<label for="cf2-message" class="block text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{content.form.fields.message.label}
</label>
<textarea
id="cf2-message"
name="message"
rows={5}
class="w-full resize-none border-b border-border bg-transparent pb-2.5 font-mono text-sm tracking-wide text-foreground placeholder:text-muted-foreground/40 focus:border-foreground focus:outline-none"
placeholder={content.form.fields.message.placeholder}
></textarea>
</div>
</div>
<div class="mt-10">
<button
type="submit"
class="inline-flex items-center gap-2.5 rounded-lg bg-primary px-6 py-3 text-xs font-semibold uppercase tracking-widest text-primary-foreground transition-colors hover:bg-primary/80"
>
{content.form.submit_label}
<SendIcon class="size-3.5" />
</button>
</div>
</form>
<!-- Full-width separator -->
<div class="absolute inset-x-0 h-px border-b"></div>
<!-- Contact boxes grid -->
<div class="grid md:grid-cols-3">
<!-- Email box -->
<div class="flex flex-col border-b md:border-r md:border-b-0">
<div class="flex items-center gap-3 border-b bg-muted/40 px-4 py-4 lg:px-6">
<MailIcon class="size-5 text-muted-foreground" strokeWidth={1} />
<h2 class="text-sm font-semibold tracking-widest uppercase">{content.email.title}</h2>
</div>
<div class="flex grow items-center gap-2 px-4 py-12 lg:px-6">
<a
href="mailto:{content.email.address}"
class="font-mono text-sm font-medium tracking-wide hover:underline"
>
{content.email.address}
</a>
{@render copyBtn(content.email.address)}
</div>
<div class="border-t px-4 py-4 lg:px-6">
<p class="text-sm text-muted-foreground">{content.email.description}</p>
</div>
</div>
<!-- Office box -->
<div class="flex flex-col border-b md:border-r md:border-b-0">
<div class="flex items-center gap-3 border-b bg-muted/40 px-4 py-4 lg:px-6">
<MapPinIcon class="size-5 text-muted-foreground" strokeWidth={1} />
<h2 class="text-sm font-semibold tracking-widest uppercase">{content.office.title}</h2>
</div>
<div class="flex grow items-center px-4 py-12 lg:px-6">
<a
href="https://maps.google.com/?q={encodeURIComponent(content.office.address)}"
target="_blank"
rel="noopener noreferrer"
class="font-mono text-sm font-medium leading-relaxed tracking-wide hover:underline"
>
{content.office.address}
</a>
</div>
<div class="border-t px-4 py-4 lg:px-6">
<p class="text-sm text-muted-foreground">{content.office.description}</p>
</div>
</div>
<!-- Phone box -->
<div class="flex flex-col border-b md:border-b-0">
<div class="flex items-center gap-3 border-b bg-muted/40 px-4 py-4 lg:px-6">
<PhoneIcon class="size-5 text-muted-foreground" strokeWidth={1} />
<h2 class="text-sm font-semibold tracking-widest uppercase">{content.phone.title}</h2>
</div>
<div class="flex grow flex-col justify-center gap-3 px-4 py-12 lg:px-6">
{#each content.phone.numbers as number}
<div class="flex items-center gap-2">
<a
href="tel:{number.replace(/\s/g, '')}"
class="font-mono text-sm font-medium tracking-wide hover:underline"
>
{number}
</a>
{@render copyBtn(number)}
</div>
{/each}
</div>
<div class="border-t px-4 py-4 lg:px-6">
<p class="text-sm text-muted-foreground">{content.phone.description}</p>
</div>
</div>
</div>
<!-- Full-width separator -->
<div class="absolute inset-x-0 h-px border-b"></div>
<!-- Find us online section -->
<div class="relative flex min-h-72 items-center justify-center overflow-hidden px-4 py-16 lg:px-6">
<!-- Dot grid background -->
<div
class="pointer-events-none absolute inset-0"
style="
background-image: radial-gradient(color-mix(in oklab, var(--foreground) 22%, transparent) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(ellipse at center, var(--background) 15%, transparent 65%);
-webkit-mask-image: radial-gradient(ellipse at center, var(--background) 15%, transparent 65%);
"
></div>
<div class="relative z-10 flex flex-col items-center gap-8">
<h2 class="text-center text-3xl font-bold md:text-4xl">{content.social.heading}</h2>
<div class="flex flex-wrap items-center justify-center gap-3">
{#each content.social.links as link}
{@const path = socialPaths[link.platform]}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 rounded-full border border-border bg-background/80 px-4 py-2 text-sm font-medium backdrop-blur-sm transition-colors hover:bg-muted"
>
{#if path}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<svg class="size-4 fill-current" viewBox="0 0 24 24" aria-hidden="true">{@html path}</svg>
{/if}
{link.label}
</a>
{/each}
</div>
</div>
</div>
</div>
</section>