Skip to content
Marketing block

Résumé Document

A CV as a document rather than a timeline of cards — one column, dates on the end edge, and a print layer that puts it on paper without a separate PDF to keep in step.

347 lineslucide-reactAdded 14 Sept 2026
  • resume
  • cv
  • personal
  • print
  • hiring

What's included

  • components/resume-document.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add resume-document

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Aisling Moreau

Principal engineer — platform, performance and the unglamorous parts

Print or save as PDF

Ten years building systems that other engineers depend on, mostly by removing things. I am at my best on a team that has outgrown its first architecture and has to keep shipping while it changes. Looking for a principal or staff role where the hard problem is technical rather than political.

Experience

  1. Principal Engineer, Platform · Northwind

    Mar 2023 — Present · Remote

    Series B logistics platform, 40 engineers, one Rails monolith and four Go services.

    • Cut p95 checkout latency from 2.4s to 610ms by deleting a caching layer rather than adding one — the invalidation cost more than the reads saved.
    • Led the migration off a managed search product onto Postgres full-text, against a hard contract-expiry date. Saved $310k a year with no change to result quality anyone could measure.
    • Wrote the on-call handbook and cut pages per engineer per week from 11 to 2, mostly by deleting alerts nobody had ever acted on.
  2. Senior Software Engineer · Contoso Health

    Jun 2019 — Feb 2023 · Dublin

    Patient records for 60 clinics. Regulated, audited, and slow to change by design.

    • Rebuilt the appointment booking flow to meet WCAG 2.1 AA before the deadline the procurement contract set. It passed audit first time, which nothing else that year did.
    • Introduced contract tests between the scheduling service and three consumers, ending a class of release that broke a downstream team every quarter.
  3. Software Engineer · Umbra Labs

    Sep 2016 — May 2019 · Dublin

    • Second engineer. Built the billing integration that the company ran on unchanged for six years.

Skills

TypeScript · Go · Postgres · React · Next.js · Terraform · OpenTelemetry · WCAG 2.2 AA · Incident command

Education

  • BA (Mod) Computer Science — Trinity College Dublin — First Class Honours2012 — 2016

Selected writing and talks

  • “The cache we deleted” — SRECon EMEA, main track2025
  • “Accessibility as a procurement requirement” — Write the Docs2024

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/resume-document.tsx
/**
 * <ResumeDocument> — a CV that is a document, and prints like one.
 *
 * Personal sites solve this badly in one of two ways. Either the CV is a
 * PDF behind a download link, which means it is a second artefact that goes
 * stale the first time a job title changes; or it is a "timeline" of
 * alternating cards with icons and connector lines, which looks like a
 * product page and prints as four pages of whitespace.
 *
 * This is the third option: one column, real headings, dates on the end
 * edge, and a `print:` layer that turns it into something a hiring manager
 * can put on paper without opening a dialog. That print layer is the whole
 * point of the block — everything else here is ordinary markup.
 *
 * WHAT THE PRINT LAYER ACTUALLY DOES, and why each part is needed:
 *
 * - `print:text-black print:bg-white` — a dark-mode CV prints as a solid
 *   black rectangle on most drivers, or as nothing at all on the ones that
 *   drop backgrounds. Colour is forced rather than inherited.
 * - `print:break-inside-avoid` on every role — the default is to split a
 *   job across the page break, which puts three bullets about a job on page
 *   two under no heading.
 * - `print:hidden` on the actions — a "Download PDF" button printed onto
 *   the PDF is the joke every CV template makes.
 * - Link URLs are already visible as text, so there is no `after:content`
 *   trick expanding hrefs in print. A CV that reads "aisling.dev" on screen
 *   and "aisling.dev (https://aisling.dev)" on paper is worse on paper.
 *
 * DATES ARE STRINGS, and the current role's end is the word "Present"
 * rather than an open range or a computed `new Date()`. Computing it would
 * be a hydration mismatch and, worse, a CV that silently changed after the
 * person left the job.
 *
 * BULLETS NAME AN OUTCOME AND A CONSTRAINT. The demo data is written that
 * way on purpose, because it is the single thing that separates a CV that
 * gets read from one that does not, and a template whose placeholder text
 * is "Responsible for various tasks" teaches the wrong shape.
 */

import * as React from 'react'
import { Mail, Globe, Github, Linkedin, MapPin, Printer } from 'lucide-react'

function instanceId(...parts: (string | undefined)[]): string {
  const text = parts.filter(Boolean).join('|')
  let hash = 0
  for (let i = 0; i < text.length; i++) hash = (Math.imul(hash, 31) + text.charCodeAt(i)) | 0
  return (hash >>> 0).toString(36).slice(0, 6)
}

export interface ResumeRole {
  title: string
  organisation: string
  /** "Mar 2023 — Present". One string, formatted by you. See the header. */
  period: string
  location?: string
  /** One line of context before the bullets: what the company is, team size. */
  summary?: string
  /** Outcome and constraint, not responsibilities. */
  bullets?: string[]
}

export interface ResumeSection {
  /** "Education", "Writing", "Open source", "Speaking". */
  heading: string
  entries: Array<{ label: string; detail?: string; period?: string }>
}

export interface ResumeLink {
  label: string
  href: string
  /** Which glyph to draw. Unknown values fall back to the globe. */
  icon?: 'mail' | 'web' | 'github' | 'linkedin' | 'location'
}

export interface ResumeDocumentProps {
  name?: string
  /** The role you are applying for, not the one you have. */
  headline?: string
  /** Three sentences at most. Anything longer is not read. */
  summary?: string
  links?: ResumeLink[]
  roles?: ResumeRole[]
  /** Skills as plain words. No five-star ratings — nobody believes them. */
  skills?: string[]
  sections?: ResumeSection[]
  /** Hidden in print. Wire it to `window.print()` in your own project. */
  printLabel?: string
  className?: string
}

const ICONS = {
  mail: Mail,
  web: Globe,
  github: Github,
  linkedin: Linkedin,
  location: MapPin,
} as const

const DEFAULT_LINKS: ResumeLink[] = [
  { label: 'aisling@moreau.dev', href: 'mailto:aisling@moreau.dev', icon: 'mail' },
  { label: 'moreau.dev', href: '#', icon: 'web' },
  { label: 'github.com/amoreau', href: '#', icon: 'github' },
  { label: 'Lisbon, Portugal (UTC+1)', href: '', icon: 'location' },
]

const DEFAULT_ROLES: ResumeRole[] = [
  {
    title: 'Principal Engineer, Platform',
    organisation: 'Northwind',
    period: 'Mar 2023 — Present',
    location: 'Remote',
    summary: 'Series B logistics platform, 40 engineers, one Rails monolith and four Go services.',
    bullets: [
      'Cut p95 checkout latency from 2.4s to 610ms by deleting a caching layer rather than adding one — the invalidation cost more than the reads saved.',
      'Led the migration off a managed search product onto Postgres full-text, against a hard contract-expiry date. Saved $310k a year with no change to result quality anyone could measure.',
      'Wrote the on-call handbook and cut pages per engineer per week from 11 to 2, mostly by deleting alerts nobody had ever acted on.',
    ],
  },
  {
    title: 'Senior Software Engineer',
    organisation: 'Contoso Health',
    period: 'Jun 2019 — Feb 2023',
    location: 'Dublin',
    summary: 'Patient records for 60 clinics. Regulated, audited, and slow to change by design.',
    bullets: [
      'Rebuilt the appointment booking flow to meet WCAG 2.1 AA before the deadline the procurement contract set. It passed audit first time, which nothing else that year did.',
      'Introduced contract tests between the scheduling service and three consumers, ending a class of release that broke a downstream team every quarter.',
    ],
  },
  {
    title: 'Software Engineer',
    organisation: 'Umbra Labs',
    period: 'Sep 2016 — May 2019',
    location: 'Dublin',
    bullets: [
      'Second engineer. Built the billing integration that the company ran on unchanged for six years.',
    ],
  },
]

const DEFAULT_SECTIONS: ResumeSection[] = [
  {
    heading: 'Education',
    entries: [
      {
        label: 'BA (Mod) Computer Science',
        detail: 'Trinity College Dublin — First Class Honours',
        period: '2012 — 2016',
      },
    ],
  },
  {
    heading: 'Selected writing and talks',
    entries: [
      { label: '“The cache we deleted”', detail: 'SRECon EMEA, main track', period: '2025' },
      { label: '“Accessibility as a procurement requirement”', detail: 'Write the Docs', period: '2024' },
    ],
  },
]

const DEFAULT_SKILLS = [
  'TypeScript',
  'Go',
  'Postgres',
  'React',
  'Next.js',
  'Terraform',
  'OpenTelemetry',
  'WCAG 2.2 AA',
  'Incident command',
]

export function ResumeDocument({
  name = 'Aisling Moreau',
  headline = 'Principal engineer — platform, performance and the unglamorous parts',
  summary = 'Ten years building systems that other engineers depend on, mostly by removing things. I am at my best on a team that has outgrown its first architecture and has to keep shipping while it changes. Looking for a principal or staff role where the hard problem is technical rather than political.',
  links = DEFAULT_LINKS,
  roles = DEFAULT_ROLES,
  skills = DEFAULT_SKILLS,
  sections = DEFAULT_SECTIONS,
  printLabel = 'Print or save as PDF',
  className = '',
}: ResumeDocumentProps) {
  const headingId = `resume-heading-${instanceId(name, headline)}`

  return (
    <article
      aria-labelledby={headingId}
      className={`mx-auto w-full max-w-3xl px-6 py-14 print:max-w-none print:px-0 print:py-0 print:text-black ${className}`}
    >
      {/* ── Masthead ───────────────────────────────────────────────── */}
      <header className="border-b border-border pb-6 print:border-black/30">
        <div className="flex flex-wrap items-start justify-between gap-4">
          <div>
            <h1
              id={headingId}
              className="text-3xl font-bold tracking-tight sm:text-4xl print:text-2xl"
            >
              {name}
            </h1>
            <p className="mt-1.5 text-pretty text-base text-muted-foreground print:text-black/70">
              {headline}
            </p>
          </div>

          {printLabel ? (
            <p className="print:hidden">
              <span className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground">
                <Printer aria-hidden className="h-3.5 w-3.5" />
                {printLabel}
              </span>
            </p>
          ) : null}
        </div>

        {links.length ? (
          <ul className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm">
            {links.map((link) => {
              const Icon = ICONS[link.icon ?? 'web'] ?? Globe
              const body = (
                <>
                  <Icon aria-hidden className="h-3.5 w-3.5 shrink-0" />
                  {link.label}
                </>
              )
              return (
                <li key={link.label}>
                  {link.href ? (
                    <a
                      href={link.href}
                      className="inline-flex items-center gap-1.5 text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline print:text-black/70 print:no-underline"
                    >
                      {body}
                    </a>
                  ) : (
                    <span className="inline-flex items-center gap-1.5 text-muted-foreground print:text-black/70">
                      {body}
                    </span>
                  )}
                </li>
              )
            })}
          </ul>
        ) : null}
      </header>

      {summary ? (
        <p className="mt-6 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground print:text-black/80">
          {summary}
        </p>
      ) : null}

      {/* ── Experience ─────────────────────────────────────────────── */}
      <section className="mt-10" aria-label="Experience">
        <h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground print:text-black/60">
          Experience
        </h2>

        <ol className="mt-5 space-y-7">
          {roles.map((role) => (
            <li key={`${role.organisation}-${role.period}`} className="print:break-inside-avoid">
              <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
                <h3 className="text-base font-semibold text-foreground print:text-black">
                  {role.title}
                  <span className="font-normal text-muted-foreground print:text-black/70">
                    {' · '}
                    {role.organisation}
                  </span>
                </h3>
                <p className="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">
                  {role.period}
                  {role.location ? ` · ${role.location}` : null}
                </p>
              </div>

              {role.summary ? (
                <p className="mt-1.5 text-sm text-muted-foreground print:text-black/70">
                  {role.summary}
                </p>
              ) : null}

              {role.bullets?.length ? (
                <ul className="mt-2.5 space-y-1.5 ps-5 text-sm leading-relaxed text-muted-foreground marker:text-muted-foreground/50 print:text-black/80 [&>li]:list-disc">
                  {role.bullets.map((bullet) => (
                    <li key={bullet}>{bullet}</li>
                  ))}
                </ul>
              ) : null}
            </li>
          ))}
        </ol>
      </section>

      {/* ── Skills ─────────────────────────────────────────────────── */}
      {skills.length ? (
        <section className="mt-10 print:break-inside-avoid" aria-label="Skills">
          <h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground print:text-black/60">
            Skills
          </h2>
          {/* Words in a row, not chips in a cloud and certainly not bars.
              A proficiency bar is a number the reader cannot check and the
              writer cannot justify. */}
          <p className="mt-3 text-sm leading-relaxed text-muted-foreground print:text-black/80">
            {skills.join(' · ')}
          </p>
        </section>
      ) : null}

      {/* ── Everything else ────────────────────────────────────────── */}
      {sections.map((section) => (
        <section
          key={section.heading}
          className="mt-10 print:break-inside-avoid"
          aria-label={section.heading}
        >
          <h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground print:text-black/60">
            {section.heading}
          </h2>
          <ul className="mt-4 space-y-3">
            {section.entries.map((entry) => (
              <li
                key={entry.label}
                className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5"
              >
                <span className="text-sm font-medium text-foreground print:text-black">
                  {entry.label}
                  {entry.detail ? (
                    <span className="font-normal text-muted-foreground print:text-black/70">
                      {' — '}
                      {entry.detail}
                    </span>
                  ) : null}
                </span>
                {entry.period ? (
                  <span className="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">
                    {entry.period}
                  </span>
                ) : null}
              </li>
            ))}
          </ul>
        </section>
      ))}
    </article>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/resume-document.tsx and import it where you need the section:

import { ResumeDocument } from '@/components/resume-document'

Customize

3 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
namestring'Aisling Moreau'
headlineThe role you are applying for, not the one you have.string'Principal engineer — platform, performance a…
summaryThree sentences at most. Anything longer is not read.string—
linksResumeLink[]DEFAULT_LINKS
rolesResumeRole[]DEFAULT_ROLES
skillsSkills as plain words. No five-star ratings — nobody believes them.string[]DEFAULT_SKILLS
sectionsResumeSection[]DEFAULT_SECTIONS
printLabelHidden in print. Wire it to `window.print()` in your own project.string'Print or save as PDF'
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

resume-document.html
<!--
  Résumé Document — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block has no interactive behaviour, so nothing is missing.
-->
<article aria-labelledby="resume-heading-1ntb4l" class="mx-auto w-full max-w-3xl px-6 py-14 print:max-w-none print:px-0 print:py-0 print:text-black ">
  <header class="border-b border-border pb-6 print:border-black/30">
    <div class="flex flex-wrap items-start justify-between gap-4">
      <div>
        <h1 id="resume-heading-1ntb4l" class="text-3xl font-bold tracking-tight sm:text-4xl print:text-2xl">Aisling Moreau</h1>
        <p class="mt-1.5 text-pretty text-base text-muted-foreground print:text-black/70">Principal engineer — platform, performance and the unglamorous parts</p>
      </div>
      <p class="print:hidden">
        <span class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-printer h-3.5 w-3.5" aria-hidden="true"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"></path><rect x="6" y="14" width="12" height="8" rx="1"></rect></svg>Print or save as PDF</span>
      </p>
    </div>
    <ul class="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm">
      <li>
        <a href="mailto:aisling@moreau.dev" class="inline-flex items-center gap-1.5 text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline print:text-black/70 print:no-underline"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail h-3.5 w-3.5 shrink-0" aria-hidden="true"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path><rect x="2" y="4" width="20" height="16" rx="2"></rect></svg>aisling@moreau.dev</a>
      </li>
      <li>
        <a href="#" class="inline-flex items-center gap-1.5 text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline print:text-black/70 print:no-underline"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe h-3.5 w-3.5 shrink-0" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path><path d="M2 12h20"></path></svg>moreau.dev</a>
      </li>
      <li>
        <a href="#" class="inline-flex items-center gap-1.5 text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline print:text-black/70 print:no-underline"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github h-3.5 w-3.5 shrink-0" aria-hidden="true"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"></path><path d="M9 18c-4.51 2-5-2-7-2"></path></svg>github.com/amoreau</a>
      </li>
      <li>
        <span class="inline-flex items-center gap-1.5 text-muted-foreground print:text-black/70"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5 shrink-0" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>Lisbon, Portugal (UTC+1)</span>
      </li>
    </ul>
  </header>
  <p class="mt-6 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground print:text-black/80">Ten years building systems that other engineers depend on, mostly by removing things. I am at my best on a team that has outgrown its first architecture and has to keep shipping while it changes. Looking for a principal or staff role where the hard problem is technical rather than political.</p>
  <section class="mt-10" aria-label="Experience">
    <h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground print:text-black/60">Experience</h2>
    <ol class="mt-5 space-y-7">
      <li class="print:break-inside-avoid">
        <div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
          <h3 class="text-base font-semibold text-foreground print:text-black">Principal Engineer, Platform<span class="font-normal text-muted-foreground print:text-black/70"> · Northwind</span></h3>
          <p class="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">Mar 2023 — Present · Remote</p>
        </div>
        <p class="mt-1.5 text-sm text-muted-foreground print:text-black/70">Series B logistics platform, 40 engineers, one Rails monolith and four Go services.</p>
        <ul class="mt-2.5 space-y-1.5 ps-5 text-sm leading-relaxed text-muted-foreground marker:text-muted-foreground/50 print:text-black/80 [&amp;amp;&amp;gt;li]:list-disc">
          <li>Cut p95 checkout latency from 2.4s to 610ms by deleting a caching layer rather than adding one — the invalidation cost more than the reads saved.</li>
          <li>Led the migration off a managed search product onto Postgres full-text, against a hard contract-expiry date. Saved $310k a year with no change to result quality anyone could measure.</li>
          <li>Wrote the on-call handbook and cut pages per engineer per week from 11 to 2, mostly by deleting alerts nobody had ever acted on.</li>
        </ul>
      </li>
      <li class="print:break-inside-avoid">
        <div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
          <h3 class="text-base font-semibold text-foreground print:text-black">Senior Software Engineer<span class="font-normal text-muted-foreground print:text-black/70"> · Contoso Health</span></h3>
          <p class="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">Jun 2019 — Feb 2023 · Dublin</p>
        </div>
        <p class="mt-1.5 text-sm text-muted-foreground print:text-black/70">Patient records for 60 clinics. Regulated, audited, and slow to change by design.</p>
        <ul class="mt-2.5 space-y-1.5 ps-5 text-sm leading-relaxed text-muted-foreground marker:text-muted-foreground/50 print:text-black/80 [&amp;amp;&amp;gt;li]:list-disc">
          <li>Rebuilt the appointment booking flow to meet WCAG 2.1 AA before the deadline the procurement contract set. It passed audit first time, which nothing else that year did.</li>
          <li>Introduced contract tests between the scheduling service and three consumers, ending a class of release that broke a downstream team every quarter.</li>
        </ul>
      </li>
      <li class="print:break-inside-avoid">
        <div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
          <h3 class="text-base font-semibold text-foreground print:text-black">Software Engineer<span class="font-normal text-muted-foreground print:text-black/70"> · Umbra Labs</span></h3>
          <p class="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">Sep 2016 — May 2019 · Dublin</p>
        </div>
        <ul class="mt-2.5 space-y-1.5 ps-5 text-sm leading-relaxed text-muted-foreground marker:text-muted-foreground/50 print:text-black/80 [&amp;amp;&amp;gt;li]:list-disc">
          <li>Second engineer. Built the billing integration that the company ran on unchanged for six years.</li>
        </ul>
      </li>
    </ol>
  </section>
  <section class="mt-10 print:break-inside-avoid" aria-label="Skills">
    <h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground print:text-black/60">Skills</h2>
    <p class="mt-3 text-sm leading-relaxed text-muted-foreground print:text-black/80">TypeScript · Go · Postgres · React · Next.js · Terraform · OpenTelemetry · WCAG 2.2 AA · Incident command</p>
  </section>
  <section class="mt-10 print:break-inside-avoid" aria-label="Education">
    <h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground print:text-black/60">Education</h2>
    <ul class="mt-4 space-y-3">
      <li class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5">
        <span class="text-sm font-medium text-foreground print:text-black">BA (Mod) Computer Science<span class="font-normal text-muted-foreground print:text-black/70"> — Trinity College Dublin — First Class Honours</span></span>
        <span class="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">2012 — 2016</span>
      </li>
    </ul>
  </section>
  <section class="mt-10 print:break-inside-avoid" aria-label="Selected writing and talks">
    <h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground print:text-black/60">Selected writing and talks</h2>
    <ul class="mt-4 space-y-3">
      <li class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5">
        <span class="text-sm font-medium text-foreground print:text-black">“The cache we deleted”<span class="font-normal text-muted-foreground print:text-black/70"> — SRECon EMEA, main track</span></span>
        <span class="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">2025</span>
      </li>
      <li class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5">
        <span class="text-sm font-medium text-foreground print:text-black">“Accessibility as a procurement requirement”<span class="font-normal text-muted-foreground print:text-black/70"> — Write the Docs</span></span>
        <span class="shrink-0 text-xs tabular-nums text-muted-foreground print:text-black/60">2024</span>
      </li>
    </ul>
  </section>
</article>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

11 more blocks in Content & Blog

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Content & Blog

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Content & Blog blocks

View category
Open the full page for this block

Release Timeline

A dated changelog on a vertical rail with added/changed/fixed tags and machine-readable <time> values.

Content & Blog145 linesNo deps
Open the full page for this block

Public Roadmap Columns

Shipped / in progress / planned in three columns, with status carried by icon as well as colour.

Content & Blog122 lines1 dep

Blog Index Grid

A featured post at full width above a card grid, with drawn covers built from tokens — a blog index with no image pipeline and no broken-thumbnail state.

Content & Blog188 lines1 dep

Case Study Card Grid

A grid of case-study cards that lead with the metric rather than the company name, each one linking out to the full write-up — the block a customer-stories section or a home page needed and only had a full case-study page for.

Content & Blog136 lines1 dep
Open the full page for this block

Article Header & Byline

Kicker, headline, standfirst, byline and share row, with the opening prose and a pull quote included — the type scale of a post, decided once instead of per-article.

Content & Blog171 lines1 dep
Open the full page for this block

Team Grid

An about-page team section on initials avatars rather than photos, so shipping it never waits on headshots and there is no broken-image state to design.

Content & Blog166 lines1 dep

Careers Openings Board

Openings grouped by department with location, type and salary on every row — candidates self-select by craft first, and a flat list makes them scan for it.

Content & Blog186 lines1 dep
Open the full page for this block

Three-Column Docs Frame

Sidebar, article and on-this-page rail — each column answers a different question, and they collapse on mobile in the order that keeps the article alive.

Content & Blog290 lines1 dep
Open the full page for this block

API Endpoint Reference

Method badge, path, parameter table and a request/response pair kept side by side — an example request whose response is a scroll away gets pasted blind.

Content & Blog237 lines1 dep
Open the full page for this block

Package Manager Code Tabs

One install command behind npm/pnpm/yarn/bun tabs with a working copy button — every reader uses exactly one package manager, so showing all four wastes three lines.

Content & Blog152 lines1 dep
Open the full page for this block

Video Player Shell

The watch screen with no playback in it: a ratio-locked stage, a control bar drawn down to the buffered layer, and an up-next rail. Drop a video element in and wire your own player.

Content & Blog327 lines1 dep