aboutsummaryrefslogtreecommitdiff
path: root/examples/portfolio/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'examples/portfolio/src/components')
-rw-r--r--examples/portfolio/src/components/CallToAction.astro56
-rw-r--r--examples/portfolio/src/components/ContactCTA.astro46
-rw-r--r--examples/portfolio/src/components/Footer.astro74
-rw-r--r--examples/portfolio/src/components/Grid.astro65
-rw-r--r--examples/portfolio/src/components/Hero.astro54
-rw-r--r--examples/portfolio/src/components/Icon.astro56
-rw-r--r--examples/portfolio/src/components/IconPaths.ts38
-rw-r--r--examples/portfolio/src/components/MainHead.astro47
-rw-r--r--examples/portfolio/src/components/Nav.astro354
-rw-r--r--examples/portfolio/src/components/Pill.astro16
-rw-r--r--examples/portfolio/src/components/PortfolioPreview.astro64
-rw-r--r--examples/portfolio/src/components/Skills.astro62
-rw-r--r--examples/portfolio/src/components/ThemeToggle.astro95
13 files changed, 1027 insertions, 0 deletions
diff --git a/examples/portfolio/src/components/CallToAction.astro b/examples/portfolio/src/components/CallToAction.astro
new file mode 100644
index 000000000..a1ca69750
--- /dev/null
+++ b/examples/portfolio/src/components/CallToAction.astro
@@ -0,0 +1,56 @@
+---
+interface Props {
+ href: string;
+}
+
+const { href } = Astro.props;
+---
+
+<a href={href}><slot /></a>
+
+<style>
+ a {
+ position: relative;
+ display: flex;
+ place-content: center;
+ text-align: center;
+ padding: 0.56em 2em;
+ gap: 0.8em;
+ color: var(--accent-text-over);
+ text-decoration: none;
+ line-height: 1.1;
+ border-radius: 999rem;
+ overflow: hidden;
+ background: var(--gradient-accent-orange);
+ box-shadow: var(--shadow-md);
+ white-space: nowrap;
+ }
+
+ @media (min-width: 20em) {
+ a {
+ font-size: var(--text-lg);
+ }
+ }
+
+ /* Overlay for hover effects. */
+ a::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ transition: background-color var(--theme-transition);
+ mix-blend-mode: overlay;
+ }
+
+ a:focus::after,
+ a:hover::after {
+ background-color: hsla(var(--gray-999-basis), 0.3);
+ }
+
+ @media (min-width: 50em) {
+ a {
+ padding: 1.125rem 2.5rem;
+ font-size: var(--text-xl);
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/ContactCTA.astro b/examples/portfolio/src/components/ContactCTA.astro
new file mode 100644
index 000000000..6986bd740
--- /dev/null
+++ b/examples/portfolio/src/components/ContactCTA.astro
@@ -0,0 +1,46 @@
+---
+import CallToAction from './CallToAction.astro';
+import Icon from './Icon.astro';
+---
+
+<aside>
+ <h2>Interested in working together?</h2>
+ <CallToAction href="mailto:me@example.com">
+ Send Me a Message
+ <Icon icon="paper-plane-tilt" size="1.2em" />
+ </CallToAction>
+</aside>
+
+<style>
+ aside {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3rem;
+ border-top: 1px solid var(--gray-800);
+ border-bottom: 1px solid var(--gray-800);
+ padding: 5rem 1.5rem;
+ background-color: var(--gray-999_40);
+ box-shadow: var(--shadow-sm);
+ }
+
+ h2 {
+ font-size: var(--text-xl);
+ text-align: center;
+ max-width: 15ch;
+ }
+
+ @media (min-width: 50em) {
+ aside {
+ padding: 7.5rem;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ }
+
+ h2 {
+ font-size: var(--text-3xl);
+ text-align: left;
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/Footer.astro b/examples/portfolio/src/components/Footer.astro
new file mode 100644
index 000000000..9d1878dad
--- /dev/null
+++ b/examples/portfolio/src/components/Footer.astro
@@ -0,0 +1,74 @@
+---
+import Icon from './Icon.astro';
+const currentYear = new Date().getFullYear();
+---
+
+<footer>
+ <div class="group">
+ <p>
+ Designed & Developed in Portland with <a href="https://astro.build/">Astro</a>
+ <Icon icon="rocket-launch" size="1.2em" />
+ </p>
+ <p>&copy; {currentYear} Jeanine White</p>
+ </div>
+ <p class="socials">
+ <a href="https://twitter.com/me"> Twitter</a>
+ <a href="https://github.com/me"> GitHub</a>
+ <a href="https://codepen.io/me"> CodePen</a>
+ </p>
+</footer>
+<style>
+ footer {
+ display: flex;
+ flex-direction: column;
+ gap: 3rem;
+ margin-top: auto;
+ padding: 3rem 2rem 3rem;
+ text-align: center;
+ color: var(--gray-400);
+ font-size: var(--text-sm);
+ }
+
+ footer a {
+ color: var(--gray-400);
+ text-decoration: 1px solid underline transparent;
+ text-underline-offset: 0.25em;
+ transition: text-decoration-color var(--theme-transition);
+ }
+
+ footer a:hover,
+ footer a:focus {
+ text-decoration-color: currentColor;
+ }
+
+ .group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .socials {
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ }
+
+ @media (min-width: 50em) {
+ footer {
+ flex-direction: row;
+ justify-content: space-between;
+ padding: 2.5rem 5rem;
+ }
+
+ .group {
+ flex-direction: row;
+ gap: 1rem;
+ flex-wrap: wrap;
+ }
+
+ .socials {
+ justify-content: flex-end;
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/Grid.astro b/examples/portfolio/src/components/Grid.astro
new file mode 100644
index 000000000..24a5ea79d
--- /dev/null
+++ b/examples/portfolio/src/components/Grid.astro
@@ -0,0 +1,65 @@
+---
+interface Props {
+ variant?: 'offset' | 'small';
+}
+
+const { variant } = Astro.props;
+---
+
+<ul class:list={['grid', { offset: variant === 'offset', small: variant === 'small' }]}>
+ <slot />
+</ul>
+
+<style>
+ .grid {
+ display: grid;
+ grid-auto-rows: 1fr;
+ gap: 1rem;
+ list-style: none;
+ padding: 0;
+ }
+
+ .grid.small {
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+ }
+
+ /* If last row contains only one item, make it span both columns. */
+ .grid.small > :global(:last-child:nth-child(odd)) {
+ grid-column: 1 / 3;
+ }
+
+ @media (min-width: 50em) {
+ .grid {
+ grid-template-columns: 1fr 1fr;
+ gap: 4rem;
+ }
+
+ .grid.offset {
+ --row-offset: 7.5rem;
+ padding-bottom: var(--row-offset);
+ }
+
+ /* Shift first item in each row vertically to create staggered effect. */
+ .grid.offset > :global(:nth-child(odd)) {
+ transform: translateY(var(--row-offset));
+ }
+
+ /* If last row contains only one item, display it in the second column. */
+ .grid.offset > :global(:last-child:nth-child(odd)) {
+ grid-column: 2 / 3;
+ transform: none;
+ }
+
+ .grid.small {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 2rem;
+ }
+
+ .grid.small > :global(*) {
+ flex-basis: 20rem;
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/Hero.astro b/examples/portfolio/src/components/Hero.astro
new file mode 100644
index 000000000..30460420a
--- /dev/null
+++ b/examples/portfolio/src/components/Hero.astro
@@ -0,0 +1,54 @@
+---
+interface Props {
+ title: string;
+ tagline?: string;
+ align?: 'start' | 'center';
+}
+
+const { align = 'center', tagline, title } = Astro.props;
+---
+
+<div class:list={['hero stack gap-4', align]}>
+ <div class="stack gap-2">
+ <h1 class="title">{title}</h1>
+ {tagline && <p class="tagline">{tagline}</p>}
+ </div>
+ <slot />
+</div>
+
+<style>
+ .hero {
+ font-size: var(--text-lg);
+ text-align: center;
+ }
+
+ .title,
+ .tagline {
+ max-width: 37ch;
+ margin-inline: auto;
+ }
+
+ .title {
+ font-size: var(--text-3xl);
+ color: var(--gray-0);
+ }
+
+ @media (min-width: 50em) {
+ .hero {
+ font-size: var(--text-xl);
+ }
+
+ .start {
+ text-align: start;
+ }
+
+ .start .title,
+ .start .tagline {
+ margin-inline: unset;
+ }
+
+ .title {
+ font-size: var(--text-5xl);
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/Icon.astro b/examples/portfolio/src/components/Icon.astro
new file mode 100644
index 000000000..92cff492a
--- /dev/null
+++ b/examples/portfolio/src/components/Icon.astro
@@ -0,0 +1,56 @@
+---
+import type { HTMLAttributes } from 'astro/types';
+import { iconPaths } from './IconPaths';
+
+interface Props {
+ icon: keyof typeof iconPaths;
+ color?: string;
+ gradient?: boolean;
+ size?: string;
+}
+
+const { color = 'currentcolor', gradient, icon, size } = Astro.props;
+const iconPath = iconPaths[icon];
+
+const attrs: HTMLAttributes<'svg'> = {};
+if (size) attrs.style = { '--size': size };
+
+const gradientId = 'icon-gradient-' + Math.round(Math.random() * 10e12).toString(36);
+---
+
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="40"
+ height="40"
+ viewBox="0 0 256 256"
+ aria-hidden="true"
+ stroke={gradient ? `url(#${gradientId})` : color}
+ fill={gradient ? `url(#${gradientId})` : color}
+ {...attrs}
+>
+ <g set:html={iconPath} />
+ {
+ gradient && (
+ <linearGradient
+ id={gradientId}
+ x1="23"
+ x2="235"
+ y1="43"
+ y2="202"
+ gradientUnits="userSpaceOnUse"
+ >
+ <stop stop-color="var(--gradient-stop-1)" />
+ <stop offset=".5" stop-color="var(--gradient-stop-2)" />
+ <stop offset="1" stop-color="var(--gradient-stop-3)" />
+ </linearGradient>
+ )
+ }
+</svg>
+
+<style>
+ svg {
+ vertical-align: middle;
+ width: var(--size, 1em);
+ height: var(--size, 1em);
+ }
+</style>
diff --git a/examples/portfolio/src/components/IconPaths.ts b/examples/portfolio/src/components/IconPaths.ts
new file mode 100644
index 000000000..f2e959f62
--- /dev/null
+++ b/examples/portfolio/src/components/IconPaths.ts
@@ -0,0 +1,38 @@
+/**
+ * Icons adapted from https://phosphoricons.com/
+ *
+ * Want to add more?
+ * 1. Find the icon you want on Phosphor Icons.
+ * 2. Click “Copy SVG”.
+ * 3. Paste the SVG code in your editor.
+ * 4. Remove the `<svg>` wrapper so you only have elements like `<path>`, `<circle>`, `<rect>` etc.
+ * 5. Remove any `stroke="#000000"` attributes
+ * 6. Replace any `fill="#000000"` attributes with `stroke="none"`
+ * (or add `stroke="none"` on shapes with no `fill` or `stroke` specified).
+ */
+export const iconPaths = {
+ 'terminal-window': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m80 96 40 32-40 32m56 0h40"/><rect width="192" height="160" x="32" y="48" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16.97" rx="8.5"/>`,
+ trophy: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M56 56v55.1c0 39.7 31.8 72.6 71.5 72.9a72 72 0 0 0 72.5-72V56a8 8 0 0 0-8-8H64a8 8 0 0 0-8 8Zm40 168h64m-32-40v40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M198.2 128h9.8a32 32 0 0 0 32-32V80a8 8 0 0 0-8-8h-32M58 128H47.9a32 32 0 0 1-32-32V80a8 8 0 0 1 8-8h32"/>`,
+ strategy: `<circle cx="68" cy="188" r="28" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m40 72 40 40m0-40-40 40m136 56 40 40m0-40-40 40M136 80V40h40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m136 40 16 16c40 40 8 88-24 96"/>`,
+ 'paper-plane-tilt': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M210.3 35.9 23.9 88.4a8 8 0 0 0-1.2 15l85.6 40.5a7.8 7.8 0 0 1 3.8 3.8l40.5 85.6a8 8 0 0 0 15-1.2l52.5-186.4a7.9 7.9 0 0 0-9.8-9.8Zm-99.4 109.2 45.2-45.2"/>`,
+ 'arrow-right': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176m-72-72 72 72-72 72"/>`,
+ 'arrow-left': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 128H40m72-72-72 72 72 72"/>`,
+ code: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m64 88-48 40 48 40m128-80 48 40-48 40M160 40 96 216"/>`,
+ 'microphone-stage': `<circle cx="168" cy="88" r="64" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m213.3 133.3-90.6-90.6M100 156l-12 12m16.8-70.1L28.1 202.5a7.9 7.9 0 0 0 .8 10.4l14.2 14.2a7.9 7.9 0 0 0 10.4.8l104.6-76.7"/>`,
+ 'pencil-line': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M96 216H48a8 8 0 0 1-8-8v-44.7a7.9 7.9 0 0 1 2.3-5.6l120-120a8 8 0 0 1 11.4 0l44.6 44.6a8 8 0 0 1 0 11.4Zm40-152 56 56"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 216H96l-55.5-55.5M164 92l-96 96"/>`,
+ 'rocket-launch': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M94.1 184.6c-11.4 33.9-56.6 33.9-56.6 33.9s0-45.2 33.9-56.6m124.5-56.5L128 173.3 82.7 128l67.9-67.9C176.3 34.4 202 34.7 213 36.3a7.8 7.8 0 0 1 6.7 6.7c1.6 11 1.9 36.7-23.8 62.4Z"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M184.6 116.7v64.6a8 8 0 0 1-2.4 5.6l-32.3 32.4a8 8 0 0 1-13.5-4.1l-8.4-41.9m11.3-101.9H74.7a8 8 0 0 0-5.6 2.4l-32.4 32.3a8 8 0 0 0 4.1 13.5l41.9 8.4"/>`,
+ list: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176M40 64h176M40 192h176"/>`,
+ heart: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 216S28 160 28 92a52 52 0 0 1 100-20h0a52 52 0 0 1 100 20c0 68-100 124-100 124Z"/>`,
+ 'moon-stars': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 112V64m24 24h-48m-24-64v32m16-16h-32m65 113A92 92 0 0 1 103 39h0a92 92 0 1 0 114 114Z"/>`,
+ sun: `<circle cx="128" cy="128" r="60" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 36V16M63 63 49 49m-13 79H16m47 65-14 14m79 13v20m65-47 14 14m13-79h20m-47-65 14-14"/>`,
+ 'twitter-logo': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 88c0-22 18.5-40.3 40.5-40a40 40 0 0 1 36.2 24H240l-32.3 32.3A127.9 127.9 0 0 1 80 224c-32 0-40-12-40-12s32-12 48-36c0 0-64-32-48-120 0 0 40 40 88 48Z"/>`,
+ 'codepen-logo': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m232 101-104 59-104-59 100.1-56.8a8.3 8.3 0 0 1 7.8 0Z"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m232 165-100.1 56.8a8.3 8.3 0 0 1-7.8 0L24 165l104-59Zm0-64v64M24 101v64m104-5v62.8m0-179.6V106"/>`,
+ 'github-logo': `<g stroke-linecap="round" stroke-linejoin="round"><path fill="none" stroke-width="14.7" d="M55.7 167.2c13.9 1 21.3 13.1 22.2 14.6 4.2 7.2 10.4 9.6 18.3 7.1l1.1-3.4a60.3 60.3 0 0 1-25.8-11.9c-12-10.1-18-25.6-18-46.3"/><path fill="none" stroke-width="16" d="M61.4 205.1a24.5 24.5 0 0 1-3-6.1c-3.2-7.9-7.1-10.6-7.8-11.1l-1-.6c-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.7 46.7 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4 0 42.6-25.8 54.7-43.6 58.7 1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3"/><path fill="none" stroke-width="16" d="M160.9 185.7c1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3A98.6 98.6 0 1 0 61.4 205c-1.4-2.1-11.3-17.5-11.8-17.8-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.4 46.4 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4.1 42.6-25.8 54.7-43.6 58.6z"/><path fill="none" stroke-width="18.7" d="m170.1 203.3 17.3-12 17.2-18.7 9.5-26.6v-27.9l-9.5-27.5" /><path fill="none" stroke-width="22.7" d="m92.1 57.3 23.3-4.6 18.7-1.4 29.3 5.4m-110 32.6-8 16-4 21.4.6 20.3 3.4 13" /><path fill="none" stroke-width="13.3" d="M28.8 133a100 100 0 0 0 66.9 94.4v-8.7c-22.4 1.8-33-11.5-35.6-19.8-3.4-8.6-7.8-11.4-8.5-11.8"/></g>`,
+ 'twitch-logo': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M165 200h-42a8 8 0 0 0-5 2l-46 38v-40H48a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8v108a8 8 0 0 1-3 6l-43 36a8 8 0 0 1-5 2Zm3-112v48m-48-48v48"/>`,
+ 'youtube-logo': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m160 128-48-32v64l48-32z"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M24 128c0 30 3 47 5 56a16 16 0 0 0 10 11c34 13 89 13 89 13s56 0 89-13a16 16 0 0 0 10-11c2-9 5-26 5-56s-3-47-5-56a16 16 0 0 0-10-11c-33-13-89-13-89-13s-55 0-89 13a16 16 0 0 0-10 11c-2 9-5 26-5 56Z"/>`,
+ 'dribbble-logo': `<circle cx="128" cy="128" r="96" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M71 205a160 160 0 0 1 137-77l16 1m-36-76a160 160 0 0 1-124 59 165 165 0 0 1-30-3"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M86 42a161 161 0 0 1 74 177"/>`,
+ 'discord-logo': `<circle stroke="none" cx="96" cy="144" r="12"/><circle stroke="none" cx="160" cy="144" r="12"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M74 80a175 175 0 0 1 54-8 175 175 0 0 1 54 8m0 96a175 175 0 0 1-54 8 175 175 0 0 1-54-8"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m155 182 12 24a8 8 0 0 0 9 4c25-6 46-16 61-30a8 8 0 0 0 3-8L206 59a8 8 0 0 0-5-5 176 176 0 0 0-30-9 8 8 0 0 0-9 5l-8 24m-53 108-12 24a8 8 0 0 1-9 4c-25-6-46-16-61-30a8 8 0 0 1-3-8L50 59a8 8 0 0 1 5-5 176 176 0 0 1 30-9 8 8 0 0 1 9 5l8 24"/>`,
+ 'linkedin-logo': `<rect width="184" height="184" x="36" y="36" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" rx="8"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M120 112v64m-32-64v64m32-36a28 28 0 0 1 56 0v36"/><circle stroke="none" cx="88" cy="80" r="12"/>`,
+ 'instagram-logo': `<circle cx="128" cy="128" r="40" fill="none" stroke-miterlimit="10" stroke-width="16"/><rect width="184" height="184" x="36" y="36" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" rx="48"/><circle cx="180" cy="76" r="12" stroke="none" />`,
+ 'tiktok-logo': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M168 106a96 96 0 0 0 56 18V84a56 56 0 0 1-56-56h-40v128a28 28 0 1 1-40-25V89a68 68 0 1 0 80 67Z"/>`,
+};
diff --git a/examples/portfolio/src/components/MainHead.astro b/examples/portfolio/src/components/MainHead.astro
new file mode 100644
index 000000000..b4c7263ff
--- /dev/null
+++ b/examples/portfolio/src/components/MainHead.astro
@@ -0,0 +1,47 @@
+---
+import '../styles/global.css';
+
+interface Props {
+ title?: string | undefined;
+ description?: string | undefined;
+}
+
+const {
+ title = 'Jeanine White: Personal Site',
+ description = 'The personal site of Jeanine White',
+} = Astro.props;
+---
+
+<meta charset="UTF-8" />
+<meta name="description" property="og:description" content={description} />
+<meta name="viewport" content="width=device-width" />
+<meta name="generator" content={Astro.generator} />
+<title>{title}</title>
+
+<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+<link rel="preconnect" href="https://fonts.googleapis.com" />
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+<link
+ href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,400;0,700;1,400&family=Rubik:wght@500;600&display=swap"
+ rel="stylesheet"
+/>
+<script is:inline>
+ // This code is inlined in the head to make dark mode instant & blocking.
+ const getThemePreference = () => {
+ if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
+ return localStorage.getItem('theme');
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ };
+ const isDark = getThemePreference() === 'dark';
+ document.documentElement.classList[isDark ? 'add' : 'remove']('theme-dark');
+
+ if (typeof localStorage !== 'undefined') {
+ // Watch the document element and persist user preference when it changes.
+ const observer = new MutationObserver(() => {
+ const isDark = document.documentElement.classList.contains('theme-dark');
+ localStorage.setItem('theme', isDark ? 'dark' : 'light');
+ });
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+ }
+</script>
diff --git a/examples/portfolio/src/components/Nav.astro b/examples/portfolio/src/components/Nav.astro
new file mode 100644
index 000000000..1d1938aec
--- /dev/null
+++ b/examples/portfolio/src/components/Nav.astro
@@ -0,0 +1,354 @@
+---
+import Icon from './Icon.astro';
+import ThemeToggle from './ThemeToggle.astro';
+import type { iconPaths } from './IconPaths';
+
+/** Main menu items */
+const textLinks: { label: string; href: string }[] = [
+ { label: 'Home', href: '/' },
+ { label: 'Work', href: '/work/' },
+ { label: 'About', href: '/about/' },
+];
+
+/** Icon links to social media — edit these with links to your profiles! */
+const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[] = [
+ { label: 'Twitter', href: 'https://twitter.com/me', icon: 'twitter-logo' },
+ { label: 'Twitch', href: 'https://twitch.tv/me', icon: 'twitch-logo' },
+ { label: 'GitHub', href: 'https://github.com/me', icon: 'github-logo' },
+ { label: 'CodePen', href: 'https://codepen.io/me', icon: 'codepen-logo' },
+ { label: 'dribbble', href: 'https://dribbble.com/me', icon: 'dribbble-logo' },
+ { label: 'YouTube', href: 'https://www.youtube.com/@me/', icon: 'youtube-logo' },
+];
+
+/** Test if a link is pointing to the current page. */
+const isCurrentPage = (href: string) => {
+ let pathname = Astro.url.pathname.replace(import.meta.env.BASE_URL, '');
+ if (pathname.at(0) !== '/') pathname = '/' + pathname;
+ if (pathname.at(-1) !== '/') pathname += '/';
+ return pathname === href || (href !== '/' && pathname.startsWith(href));
+};
+---
+
+<nav>
+ <div class="menu-header">
+ <a href="/" class="site-title">
+ <Icon icon="terminal-window" color="var(--accent-regular)" size="1.6em" gradient />
+ Jeanine White
+ </a>
+ <menu-button>
+ <template>
+ <button class="menu-button" aria-expanded="false">
+ <span class="sr-only">Menu</span>
+ <Icon icon="list" />
+ </button>
+ </template>
+ </menu-button>
+ </div>
+ <noscript>
+ <ul class="nav-items">
+ {
+ textLinks.map(({ label, href }) => (
+ <li>
+ <a aria-current={isCurrentPage(href) ? 'page' : null} class="link" href={href}>
+ {label}
+ </a>
+ </li>
+ ))
+ }
+ </ul>
+ </noscript>
+ <noscript>
+ <div class="menu-footer">
+ <div class="socials">
+ {
+ iconLinks.map(({ href, icon, label }) => (
+ <a href={href} class="social">
+ <span class="sr-only">{label}</span>
+ <Icon icon={icon} />
+ </a>
+ ))
+ }
+ </div>
+ </div>
+ </noscript>
+ <div id="menu-content" hidden>
+ <ul class="nav-items">
+ {
+ textLinks.map(({ label, href }) => (
+ <li>
+ <a aria-current={isCurrentPage(href) ? 'page' : null} class="link" href={href}>
+ {label}
+ </a>
+ </li>
+ ))
+ }
+ </ul>
+ <div class="menu-footer">
+ <div class="socials">
+ {
+ iconLinks.map(({ href, icon, label }) => (
+ <a href={href} class="social">
+ <span class="sr-only">{label}</span>
+ <Icon icon={icon} />
+ </a>
+ ))
+ }
+ </div>
+
+ <div class="theme-toggle">
+ <ThemeToggle />
+ </div>
+ </div>
+ </div>
+</nav>
+
+<script>
+ class MenuButton extends HTMLElement {
+ constructor() {
+ super();
+
+ // Inject menu toggle button when JS runs.
+ this.appendChild(this.querySelector('template')!.content.cloneNode(true));
+ const btn = this.querySelector('button')!;
+
+ // Hide menu (shown by default to support no-JS browsers).
+ const menu = document.getElementById('menu-content')!;
+ menu.hidden = true;
+ // Add "menu-content" class in JS to avoid covering content in non-JS browsers.
+ menu.classList.add('menu-content');
+
+ /** Set whether the menu is currently expanded or collapsed. */
+ const setExpanded = (expand: boolean) => {
+ btn.setAttribute('aria-expanded', expand ? 'true' : 'false');
+ menu.hidden = !expand;
+ };
+
+ // Toggle menu visibility when the menu button is clicked.
+ btn.addEventListener('click', () => setExpanded(menu.hidden));
+
+ // Hide menu button for large screens.
+ const handleViewports = (e: MediaQueryList | MediaQueryListEvent) => {
+ setExpanded(e.matches);
+ btn.hidden = e.matches;
+ };
+ const mediaQueries = window.matchMedia('(min-width: 50em)');
+ handleViewports(mediaQueries);
+ mediaQueries.addEventListener('change', handleViewports);
+ }
+ }
+ customElements.define('menu-button', MenuButton);
+</script>
+
+<style>
+ nav {
+ z-index: 9999;
+ position: relative;
+ font-family: var(--font-brand);
+ font-weight: 500;
+ margin-bottom: 3.5rem;
+ }
+
+ .menu-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 1.5rem;
+ }
+
+ .site-title {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ line-height: 1.1;
+ color: var(--gray-0);
+ text-decoration: none;
+ }
+
+ .menu-button {
+ position: relative;
+ display: flex;
+ border: 0;
+ border-radius: 999rem;
+ padding: 0.5rem;
+ font-size: 1.5rem;
+ color: var(--gray-300);
+ background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
+ box-shadow: var(--shadow-md);
+ }
+
+ .menu-button[aria-expanded='true'] {
+ color: var(--gray-0);
+ background:
+ linear-gradient(180deg, var(--gray-600), transparent),
+ radial-gradient(var(--gray-900), var(--gray-800) 150%);
+ }
+
+ .menu-button[hidden] {
+ display: none;
+ }
+
+ .menu-button::before {
+ position: absolute;
+ inset: -1px;
+ content: '';
+ background: var(--gradient-stroke);
+ border-radius: 999rem;
+ z-index: -1;
+ }
+
+ .menu-content {
+ position: absolute;
+ left: 0;
+ right: 0;
+ }
+
+ .nav-items {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ font-size: var(--text-md);
+ line-height: 1.2;
+ list-style: none;
+ padding: 2rem;
+ background-color: var(--gray-999);
+ border-bottom: 1px solid var(--gray-800);
+ }
+
+ .link {
+ display: inline-block;
+ color: var(--gray-300);
+ text-decoration: none;
+ }
+
+ .link[aria-current] {
+ color: var(--gray-0);
+ }
+
+ .menu-footer {
+ --icon-size: var(--text-xl);
+ --icon-padding: 0.5rem;
+
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 1.5rem 2rem 1.5rem 1.5rem;
+ background-color: var(--gray-999);
+ border-radius: 0 0 0.75rem 0.75rem;
+ box-shadow: var(--shadow-lg);
+ }
+
+ .socials {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.625rem;
+ font-size: var(--icon-size);
+ }
+
+ .social {
+ display: flex;
+ padding: var(--icon-padding);
+ text-decoration: none;
+ color: var(--accent-dark);
+ transition: color var(--theme-transition);
+ }
+
+ .social:hover,
+ .social:focus {
+ color: var(--accent-text-over);
+ }
+
+ .theme-toggle {
+ display: flex;
+ align-items: center;
+ height: calc(var(--icon-size) + 2 * var(--icon-padding));
+ }
+
+ @media (min-width: 50em) {
+ nav {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ padding: 2.5rem 5rem;
+ gap: 1rem;
+ }
+
+ .menu-header {
+ padding: 0;
+ }
+
+ .site-title {
+ font-size: var(--text-lg);
+ }
+
+ .menu-content {
+ display: contents;
+ }
+
+ .nav-items {
+ position: relative;
+ flex-direction: row;
+ font-size: var(--text-sm);
+ border-radius: 999rem;
+ border: 0;
+ padding: 0.5rem 0.5625rem;
+ background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
+ box-shadow: var(--shadow-md);
+ }
+
+ .nav-items::before {
+ position: absolute;
+ inset: -1px;
+ content: '';
+ background: var(--gradient-stroke);
+ border-radius: 999rem;
+ z-index: -1;
+ }
+
+ .link {
+ padding: 0.5rem 1rem;
+ border-radius: 999rem;
+ transition:
+ color var(--theme-transition),
+ background-color var(--theme-transition);
+ }
+
+ .link:hover,
+ .link:focus {
+ color: var(--gray-100);
+ background-color: var(--accent-subtle-overlay);
+ }
+
+ .link[aria-current='page'] {
+ color: var(--accent-text-over);
+ background-color: var(--accent-regular);
+ }
+
+ .menu-footer {
+ --icon-padding: 0.375rem;
+
+ justify-self: flex-end;
+ align-items: center;
+ padding: 0;
+ background-color: transparent;
+ box-shadow: none;
+ }
+
+ .socials {
+ display: none;
+ }
+ }
+
+ @media (min-width: 60em) {
+ .socials {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0;
+ }
+ }
+ @media (forced-colors: active) {
+ .link[aria-current='page'] {
+ color: SelectedItem;
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/Pill.astro b/examples/portfolio/src/components/Pill.astro
new file mode 100644
index 000000000..2c410faa0
--- /dev/null
+++ b/examples/portfolio/src/components/Pill.astro
@@ -0,0 +1,16 @@
+<div class="pill"><slot /></div>
+
+<style>
+ .pill {
+ display: flex;
+ padding: 0.5rem 1rem;
+ gap: 0.5rem;
+ color: var(--accent-text-over);
+ border: 1px solid var(--accent-regular);
+ background-color: var(--accent-regular);
+ border-radius: 999rem;
+ font-size: var(--text-md);
+ line-height: 1.35;
+ white-space: nowrap;
+ }
+</style>
diff --git a/examples/portfolio/src/components/PortfolioPreview.astro b/examples/portfolio/src/components/PortfolioPreview.astro
new file mode 100644
index 000000000..f26bae0e2
--- /dev/null
+++ b/examples/portfolio/src/components/PortfolioPreview.astro
@@ -0,0 +1,64 @@
+---
+import type { CollectionEntry } from 'astro:content';
+
+interface Props {
+ project: CollectionEntry<'work'>;
+}
+
+const { data, id } = Astro.props.project;
+---
+
+<a class="card" href={`/work/${id}`}>
+ <span class="title">{data.title}</span>
+ <img src={data.img} alt={data.img_alt || ''} loading="lazy" decoding="async" />
+</a>
+
+<style>
+ .card {
+ display: grid;
+ grid-template: auto 1fr / auto 1fr;
+ height: 11rem;
+ background: var(--gradient-subtle);
+ border: 1px solid var(--gray-800);
+ border-radius: 0.75rem;
+ overflow: hidden;
+ box-shadow: var(--shadow-sm);
+ text-decoration: none;
+ font-family: var(--font-brand);
+ font-size: var(--text-lg);
+ font-weight: 500;
+ transition: box-shadow var(--theme-transition);
+ }
+
+ .card:hover {
+ box-shadow: var(--shadow-md);
+ }
+
+ .title {
+ grid-area: 1 / 1 / 2 / 2;
+ z-index: 1;
+ margin: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--gray-999);
+ color: var(--gray-200);
+ border-radius: 0.375rem;
+ }
+
+ img {
+ grid-area: 1 / 1 / 3 / 3;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ @media (min-width: 50em) {
+ .card {
+ height: 22rem;
+ border-radius: 1.5rem;
+ }
+
+ .title {
+ border-radius: 0.9375rem;
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/Skills.astro b/examples/portfolio/src/components/Skills.astro
new file mode 100644
index 000000000..5df5bb0d3
--- /dev/null
+++ b/examples/portfolio/src/components/Skills.astro
@@ -0,0 +1,62 @@
+---
+import Icon from './Icon.astro';
+---
+
+<section class="box skills">
+ <div class="stack gap-2 lg:gap-4">
+ <Icon icon="terminal-window" color="var(--accent-regular)" size="2.5rem" gradient />
+ <h2>Full Stack</h2>
+ <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod.</p>
+ </div>
+ <div class="stack gap-2 lg:gap-4">
+ <Icon icon="trophy" color="var(--accent-regular)" size="2.5rem" gradient />
+ <h2>Industry Leader</h2>
+ <p>Neque viverra justo nec ultrices dui. Est ultricies integer quis auctor elit.</p>
+ </div>
+ <div class="stack gap-2 lg:gap-4">
+ <Icon icon="strategy" color="var(--accent-regular)" size="2.5rem" gradient />
+ <h2>Strategy-Minded</h2>
+ <p>Urna porttitor rhoncus dolor purus non enim praesent ornare.</p>
+ </div>
+</section>
+
+<style>
+ .box {
+ border: 1px solid var(--gray-800);
+ border-radius: 0.75rem;
+ padding: 1.5rem;
+ background-color: var(--gray-999_40);
+ box-shadow: var(--shadow-sm);
+ }
+
+ .skills {
+ display: flex;
+ flex-direction: column;
+ gap: 3rem;
+ }
+
+ .skills h2 {
+ font-size: var(--text-lg);
+ }
+
+ .skills p {
+ color: var(--gray-400);
+ }
+
+ @media (min-width: 50em) {
+ .box {
+ border-radius: 1.5rem;
+ padding: 2.5rem;
+ }
+
+ .skills {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 5rem;
+ }
+
+ .skills h2 {
+ font-size: var(--text-2xl);
+ }
+ }
+</style>
diff --git a/examples/portfolio/src/components/ThemeToggle.astro b/examples/portfolio/src/components/ThemeToggle.astro
new file mode 100644
index 000000000..88f7bf67c
--- /dev/null
+++ b/examples/portfolio/src/components/ThemeToggle.astro
@@ -0,0 +1,95 @@
+---
+import Icon from './Icon.astro';
+---
+
+<theme-toggle>
+ <button>
+ <span class="sr-only">Dark theme</span>
+ <span class="icon light"><Icon icon="sun" /></span>
+ <span class="icon dark"><Icon icon="moon-stars" /></span>
+ </button>
+</theme-toggle>
+
+<style>
+ button {
+ display: flex;
+ border: 0;
+ border-radius: 999rem;
+ padding: 0;
+ background-color: var(--gray-999);
+ box-shadow: inset 0 0 0 1px var(--accent-overlay);
+ cursor: pointer;
+ }
+
+ .icon {
+ z-index: 1;
+ position: relative;
+ display: flex;
+ padding: 0.5rem;
+ width: 2rem;
+ height: 2rem;
+ font-size: 1rem;
+ color: var(--accent-overlay);
+ }
+
+ .icon.light::before {
+ content: '';
+ z-index: -1;
+ position: absolute;
+ inset: 0;
+ background-color: var(--accent-regular);
+ border-radius: 999rem;
+ }
+
+ :global(.theme-dark) .icon.light::before {
+ transform: translateX(100%);
+ }
+
+ :global(.theme-dark) .icon.dark,
+ :global(html:not(.theme-dark)) .icon.light,
+ button[aria-pressed='false'] .icon.light {
+ color: var(--accent-text-over);
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ .icon,
+ .icon.light::before {
+ transition:
+ transform var(--theme-transition),
+ color var(--theme-transition);
+ }
+ }
+
+ @media (forced-colors: active) {
+ .icon.light::before {
+ background-color: SelectedItem;
+ }
+ }
+</style>
+
+<script>
+ class ThemeToggle extends HTMLElement {
+ constructor() {
+ super();
+
+ const button = this.querySelector('button')!;
+
+ /** Set the theme to dark/light mode. */
+ const setTheme = (dark: boolean) => {
+ document.documentElement.classList[dark ? 'add' : 'remove']('theme-dark');
+ button.setAttribute('aria-pressed', String(dark));
+ };
+
+ // Toggle the theme when a user clicks the button.
+ button.addEventListener('click', () => setTheme(!this.isDark()));
+
+ // Initialize button state to reflect current theme.
+ setTheme(this.isDark());
+ }
+
+ isDark() {
+ return document.documentElement.classList.contains('theme-dark');
+ }
+ }
+ customElements.define('theme-toggle', ThemeToggle);
+</script>