Crafting A Minimalist Portfolio Website with SvelteKit and Pico CSS
Tags: webdev, svelte, sveltekit, picocss
Published: 2023-10-14 10:50:20

Creating a portfolio website can be a practical way to showcase resumes, projects, and blogs. As someone who leans more towards backend development, I found myself in the situation of wanting to establish an online presence without drowning myself into the complexities of [React](https://react.dev) or dealing with the manual development with plain HTML, CSS, and JavaScript. It was in the search for a simpler alternatives that I found [Svelte](https://svelte.dev), and its meta-framework, [SvelteKit](https://kit.svelte.dev).

The simplicity of Svelte's syntax, its templates, and its approach to frontend development, which relies on [compilation rather than a virtual DOM](https://svelte.dev/blog/virtual-dom-is-pure-overhead), has won me over. And then there is SvelteKit, an extension of Svelte that simplifies the process of building websites, which made it a great choice for my portfolio website project. It offered a perfect balance between simplicity and powerful functionality that aligns well with my preferences.

However, the question of CSS styling remained. The vast ecosystem of [Tailwind CSS](https://tailwindcss.com), with its extensive class-based utility system and configurations, felt kind of overwhelming for my needs. On the other hand, creating styles entirely from scratch using pure CSS felt time-consuming and inefficient.

My search for a middle ground led me to [Pico CSS](https://picocss.com), a minimalist CSS framework with a unique approach to styling. What makes Pico CSS different is its focus on elegant styling without the use of classes. Instead, it encourages the use of semantic HTML tags to implement predefined styles rather than abusing `<div>` tags with CSS classes, making it an interesting choice for those who prefer to keep their HTML clean and meaningful.

In this blog post, I will try to explain the technical aspects of how I utilized SvelteKit and Pico CSS to create a portfolio website that effectively presents my resume, projects, and blogs.

## Initiate SvelteKit Project

Getting started with a SvelteKit project is a straight-forward process. The easiest way is to run `npm create` command:

```bash
npm create svelte@latest my-app
```

It will scaffold a new project in the `my-app` directory, asking questions to set up some basic tooling such as TypeScript. However, I'm using JSDoc for this project, because it is good enough for my needs.

SvelteKit follows conventions that encourage efficient code organization. There are two basic concepts:
- Each page is a Svelte component.
- Create more pages by adding files to the `src/routes` directory of the project.

I used the index page for my resume. Then, I created 2 specific directories, `blogs` and `projects`, along with their mandatory `+page.svelte` and `+page.server.js` files. These files served as the components that rendered the content for my `/blogs` and `/projects` pages respectively.

```
src/routes/
├── blogs
│   ├── +page.server.js
│   └── +page.svelte
├── +error.svelte
├── +layout.svelte
├── +page.svelte
└── projects
    ├── +page.server.js
    └── +page.svelte

3 directories, 7 files
```

Additionally, I added a `+error.svelte` file for rendering custom error messages when needed.

```html
<script>
	import { page } from '$app/stores';

	/** @type {Record<number, string>} map of status codes to emojis */
	const emojis = {
		400: '❌', // bad request
		401: '🔒', // unauthorized
		403: '🚫', // forbidden
		404: '🔍', // not found
		500: '🛠', // internal server error
		502: '🔌', // bad gateway
		503: '🔧', // service unavailable
		504: '⏳' // gateway timeout
	};
</script>

<section id="error" class="container">
	<h1><strong>{$page.status}</strong></h1>
	<h2>{$page.error?.message}</h2>
	<span id="emoji">
		{emojis[$page.status] ?? emojis[500]}
	</span>
</section>

<style>
	h1,
	h2 {
		margin-bottom: 1rem;
	}

	#error {
		display: flex;
		flex-direction: column;
		justify-content: center;
		align-items: center;
		text-align: center;
		margin: 0rem;
	}

	#emoji {
		font-size: 10rem;
	}
</style>
```

For static data, I put them as a JSON object inside `src/lib/store.js` file.

```javascript
/**
 * Experience data for the resume.
 *
 * @type {Array<{
 *   job: string,
 *   url: string,
 *   company: string,
 *   start: string,
 *   end: string,
 *   description: Array<string>
 * }>}
 */
export const experiences = [
// ...
];

/**
 * Volunteering data for the resume.
 *
 * @type {Array<{
 *   job: string,
 *   url: string,
 *   company: string,
 *   start: string,
 *   end: string,
 *   description: Array<string>
 * }>}
 */
export const volunteering = [
// ...
];

/**
 * Social data for the resume.
 *
 * @type {Array<{
 *   href: string,
 *   rel: string,
 *   fa_class: string,
 * }>}
 */
export const socials = [
// ...
];

/**
 * Education data for the resume.
 *
 * @type {Array<{
 *  school: string,
 * url: string,
 * major: string,
 * start: string,
 * end: string,
 * description: string
 * }>}
 */
export const educations = [
// ...
];

/** Font Awesome icons name for the skills data. */
export const skills = [
// ...
];

/** Workflows data for the resume. */
export const workflows = [
// ...
];

/**
 * Awards data for the resume.
 *
 * @type {Array<{
 * place: number,
 * suffix: string,
 * host: string,
 * competition: string,
 * translation: string
 * }>}
 */
export const awards = [
// ...
];

/**
 * Certification data for the resume.
 *
 * @type {Array<{
 *  title: string,
 * credential_id: string,
 * credential_url: string
 * }>}
 */
export const certifications = [
// ...
];
```

I also put all the necessary component files inside the `src/lib/components` directory.

```
src/lib/components/
├── BlogCard.svelte
├── EduCard.svelte
├── Footer.svelte
├── JobCard.svelte
├── Navbar.svelte
├── ProjectCard.svelte
├── SkillsCard.svelte
└── SocialIcon.svelte

1 directory, 8 files
```

And for JavaScript files, I put them in the `src/lib/scripts` directory.

```
src/lib/scripts/
├── minimal-theme-switcher.js
└── redis.js

1 directory, 2 files
```

## Styling with Pico CSS

To get started with Pico CSS, all I had to do was install it via npm by using the straight-forward command:

```bash
npm install -D @picocss/pico
```

After that, I included it into my project by importing it in the `+layout.svelte` file, specifically inside the `<script>` tag.

```html
<script>
	// ...
	import '@picocss/pico';
	// ...
</script>
```

And that was basically it. I was able to use Pico CSS's elegant styles for all native HTML elements without the need for classes, enhancing the overall aesthetics of my website.

I also *borrowed* some code from Pico CSS's example code for a theme switcher by manipulating the `data-theme` attribute for the `<html>` tag using JavaScript. I placed the code inside `src/lib/scripts/minimal-theme-switcher.js` file.

```javascript
/*!
 * Minimal theme switcher
 *
 * Pico.css - https://picocss.com
 * Copyright 2019-2023 - Licensed under MIT
 */

/**
 * Minimal theme switcher
 *
 * @namespace
 * @typedef {Object} ThemeSwitcher
 * @property {string} _scheme - The current color scheme ("auto", "light", or "dark").
 * @property {string} menuTarget - The selector for the menu element that contains theme switchers.
 * @property {string} buttonsTarget - The selector for theme switcher buttons.
 * @property {string} buttonAttribute - The attribute name used for theme switcher buttons.
 * @property {string} rootAttribute - The attribute name used for the root HTML element to store the selected theme.
 * @property {string} localStorageKey - The key used to store the preferred color scheme in local storage.
 */
export const ThemeSwitcher = {
	// Config
	_scheme: 'auto',
	menuTarget: "details[role='list']",
	buttonsTarget: 'a[data-theme-switcher]',
	buttonAttribute: 'data-theme-switcher',
	rootAttribute: 'data-theme',
	localStorageKey: 'picoPreferredColorScheme',

	/**
	 * Initialize the theme switcher.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 */
	init() {
		this.scheme = this.schemeFromLocalStorage || this.preferredColorScheme;
		this.initSwitchers();
	},

	/**
	 * Get the color scheme from local storage or use the preferred color scheme.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 * @returns {string|null} The color scheme ("light", "dark", or null).
	 */
	get schemeFromLocalStorage() {
		if (typeof window.localStorage !== 'undefined') {
			if (window.localStorage.getItem(this.localStorageKey) !== null) {
				return window.localStorage.getItem(this.localStorageKey);
			}
		}
		return this._scheme;
	},

	/**
	 * Get the preferred color scheme based on user preferences.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 * @returns {string} The preferred color scheme ("light" or "dark").
	 */
	get preferredColorScheme() {
		return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
	},

	/**
	 * Initialize the theme switcher buttons and their click events.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 */
	initSwitchers() {
		const buttons = document.querySelectorAll(this.buttonsTarget);
		buttons.forEach((button) => {
			button.addEventListener(
				'click',
				(event) => {
					event.preventDefault();
					// Set scheme
					this.scheme = button.getAttribute(this.buttonAttribute) || 'auto';
					// Close dropdown
					document.querySelector(this.menuTarget)?.removeAttribute('open');
				},
				false
			);
		});
	},

	/**
	 * Set the selected color scheme and update the UI.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 * @param {string} scheme - The color scheme to set ("auto", "light", or "dark").
	 */
	set scheme(scheme) {
		if (scheme == 'auto') {
			this.preferredColorScheme == 'dark' ? (this._scheme = 'dark') : (this._scheme = 'light');
		} else if (scheme == 'dark' || scheme == 'light') {
			this._scheme = scheme;
		}
		this.applyScheme();
		this.schemeToLocalStorage();
	},

	/**
	 * Get the current color scheme.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 * @returns {string} The current color scheme ("auto", "light", or "dark").
	 */
	get scheme() {
		return this._scheme;
	},

	/**
	 * Apply the selected color scheme to the HTML root element.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 */
	applyScheme() {
		document.querySelector('html')?.setAttribute(this.rootAttribute, this.scheme);
	},

	/**
	 * Store the selected color scheme in local storage.
	 *
	 * @function
	 * @memberof ThemeSwitcher
	 */
	schemeToLocalStorage() {
		if (typeof window.localStorage !== 'undefined') {
			window.localStorage.setItem(this.localStorageKey, this.scheme);
		}
	}
};
```

Then, I used it in the `Navbar.svelte` component within the `<script>` section, utilizing Svelte's `onMount` feature.

```html
<script>
	import { ThemeSwitcher } from '$lib/scripts/minimal-theme-switcher';
	import { onMount } from 'svelte';

	onMount(() => {
		ThemeSwitcher.init();
	});
</script>
```

## Font Awesome Icons

Utilizing Font Awesome icons in my project was easy. By integrating the [Font Awesome Kit](https://fontawesome.com/docs/web/setup/use-kit), I have access to a vast icon library that covers various categories, from social media icons to common UI elements. Even though using the kit may not be the fastest way to render icons on the web, the flexibility of the kit allows me to select and combine icons that blend seamlessly with my website's design, adding aesthetic value and a better user experience.

## Fetch Blogs Data from Dev.to API

For populating my `/blogs` page with my published blog articles, I utilized Dev.to's API as provided in their [documentation](https://developers.forem.com/api/v1#tag/articles/operation/getArticles). I included a `username` query parameter with my username as the value:

```
https://dev.to/api/articles?username=bagashiz
```

This query allowed me to get my published blog articles from the API and integrate them into my `/blogs` page on my portfolio website.

To enhance user experience and website performance, I implemented a strategy where I returned the data as a [streamed promise](https://kit.svelte.dev/docs/load#streaming-with-promises).

```javascript
import { env } from '$env/dynamic/private';

/** @type {import('./$types').PageServerLoad} */
export async function load() {
	const url = env.BLOG_URL;
	return {
		streamed: {
			/**
			 * @type {Promise<Blog[]>} blogs - Array of dev.to blogs
			 */
			blogs: new Promise((resolve) => {
				fetch(url)
					.then((res) => res.json())
					.then((blogs) => {
						resolve(blogs);
					});
				}
			})
		}
	};
}
```

This approach allowed me to render the page with a placeholder before the actual blog data was fetched. 

```html
<script>
	import BlogCard from '$lib/components/BlogCard.svelte';

	/** @type {import('./$types').PageData} */
	export let data;
</script>

<section>
<!-- ... -->

	{#await data.streamed.blogs}
		<article aria-busy="true" id="skeleton" class="outline">
			<strong>Fetching data...</strong>
		</article>
	{:then blogs}
		{#each blogs as blog}
			<BlogCard {blog} />
		{/each}
	{:catch}
		<article id="skeleton" class="outline">
			<strong>Uh oh! Failed to fetch data.</strong>
		</article>
	{/await}
</section>

<style>
/* ... */
</style>
```

This approach not only ensured a smooth and responsive user experience but also provided an nice way to handle the loading of external data.

## Fetch Projects Data from GitHub GraphQL API

To populate my `/projects` page with information about my pinned GitHub repositories, I utilized [GitHub GraphQL API](https://docs.github.com/en/graphql). This API allows for more specific and efficient data retrieval compared to traditional REST APIs.

However, fetching data from the GitHub GraphQL API involved a slightly different process. Instead of using simple `GET` requests, I needed to use the `POST` method. To specify the data I wanted to retrieve, I included a GraphQL query in the request body:

```graphql
{
  user(login: "bagashiz") {
    pinnedItems(first: 6, types: REPOSITORY) {
      nodes {
        ... on Repository {
          name
          description
          url
          primaryLanguage {
            name
			color
          }
          stargazers {
            totalCount
          }
          forks {
            totalCount
          }
        }
      }
    }
  }
}
```

This query allowed me to request my pinned GitHub repositories, such as repository names, descriptions, stars and forks count, primary languages, and links.

Additionally, I also needed to add a bearer token to the Authorization header of the request. This token was generated using a GitHub personal access token with the necessary permissions, including access to public repositories and the ability to read all user profile data.

```javascript
import { env } from '$env/dynamic/private';

/**
 * @type {string} url - Github GraphQL API endpoint
 */
const url = 'https://api.github.com/graphql';

/**
 * @type {string} query - GraphQL query to fetch pinned github repositories
 */
const query = `...`;

/** @type {import('./$types').PageServerLoad} */
export async function load() {
	const key = 'projects';
	const token = env.GITHUB_ACCESS_TOKEN;
	const reqInfo = {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			Authorization: `bearer ${token}`
		},
		body: JSON.stringify({ query })
	};

	return {
		streamed: {
			/**
			 * @type {Promise<Project[]>} projects - Array of GitHub projects
			 */
			projects: new Promise((resolve) => {
					fetch(url, reqInfo)
						.then((res) => res.json())
						.then(({ data }) => {
							const projects = data.user.pinnedItems.nodes;
							resolve(projects);
						});
				}
			})
		}
	};
}
```

## Caching API Data with Redis

To make data retrieval efficient and minimize the need to request data from the API on every page load, I implemented a caching strategy using the [ioredis](https://github.com/redis/ioredis) package, which is a popular redis client for Node.js for interacting with a [Redis](https://redis.io) database.

```javascript
import { env } from '$env/dynamic/private';
import { Redis } from 'ioredis';

/**
 * @type {Redis} redis - Redis client instance
 */
const redis = new Redis({
	host: env.REDIS_HOST,
	port: parseInt(env.REDIS_PORT || '6379')
});

redis.on('error', (error) => {
	console.error(`Error initializing redis client: ${error}`);
});

export default redis;
```

For caching strategy, I implemented the [Cache-Aside](https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside) pattern. This process involves fetching data from an external API, storing it in the Redis cache, and using the cached data for the next request. I also configured the cache to expire after an hour, making sure to keep it efficient and up-to-date.

```javascript
const key = 'blogs';
// ...
return {
	streamed: {
		/**
		 * @type {Promise<Blog[]>} blogs - Array of dev.to blogs
		 */
		blogs: new Promise((resolve) => {
			redis.get(key).then((data) => {
				if (data) {
					const blogs = JSON.parse(data);
					resolve(blogs);
				} else {
					const timestamp = new Date().toISOString();
					console.log(`[${timestamp}] Cache miss, getting data from dev.to API`);

					fetch(url)
						.then((res) => res.json())
						.then((blogs) => {
							redis.setex(key, 3600, JSON.stringify(blogs));
							resolve(blogs);
						});
				}
			});
		})
	}
};
// ...
```

```javascript
const key = 'projects';
// ...
return {
	streamed: {
		/**
		 * @type {Promise<Project[]>} projects - Array of GitHub projects
		 */
		projects: new Promise((resolve) => {
			redis.get(key).then((data) => {
				if (data) {
					const projects = JSON.parse(data);
					resolve(projects);
				} else {
					const timestamp = new Date().toISOString();
					console.log(`[${timestamp}] Cache miss, getting data from GitHub API`);

					fetch(url, reqInfo)
						.then((res) => res.json())
						.then(({ data }) => {
							const projects = data.user.pinnedItems.nodes;
							redis.setex(key, 3600, JSON.stringify(projects));
							resolve(projects);
						});
				}
			});
		})
	}
};
// ...
```

## Trying Out New View Transitions API

The [view transitions API](https://developer.chrome.com/docs/web-platform/view-transitions)  streamlines the process of animating between two page states, which is especially useful for page transitions. This excellent [blog post](https://svelte.dev/blog/view-transitions) by [Geoff Rich](https://geoffrich.net) offered valuable insights and guidelines on implementing this new feature to my project.

I set up the new View Transitions API in my SvelteKit project using the `onNavigate()` function inside the `+layout.svelte` file. This function enabled the transitions between different page states seamlessly.

```html
<script>
// ...
	// view transition between routes
	onNavigate((navigation) => {
		// @ts-ignore
		if (!document.startViewTransition) return;

		return new Promise((resolve) => {
			// @ts-ignore
			document.startViewTransition(async () => {
				resolve();
				await navigation.complete;
			});
		});
	});
// ...
</script>
```

I also implemented custom transitions using CSS. These custom transitions were defined inside the `app.css` file.

```css
@keyframes fade-in {
	from {
		opacity: 0;
	}
}

@keyframes fade-out {
	to {
		opacity: 0;
	}
}

@keyframes slide-from-right {
	from {
		transform: translateX(30px);
	}
}

@keyframes slide-to-left {
	to {
		transform: translateX(-30px);
	}
}

/**
 * slide animation if prefers-reduced-motion is not set,
 * else default cross-fade animation
 */
@media (prefers-reduced-motion: no-preference) {
	:root::view-transition-old(root) {
		animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
			300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
	}

	:root::view-transition-new(root) {
		animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
			300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
	}
}
```

## Deployment

With SvelteKit, deploying my portfolio website can't get any simpler. The first step is to switch from the default SvelteKit adapter to using the [Node.js adapter](https://kit.svelte.dev/docs/adapter-node).

Then I created a Dockerfile that specified all the necessary dependencies and configurations. I used a multi-stage build approach to reduce the size of the final image. 

```Dockerfile
FROM node:18.18.0-alpine3.18 AS build

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18.18.0-alpine3.18 AS prod

USER node:node
WORKDIR /app

COPY --from=build --chown=node:node /app/build ./build
COPY --from=build --chown=node:node /app/package.json ./

RUN npm i --omit=dev

CMD ["node", "build"]
```

After that, I set up a GitHub workflow to build and publish the Docker image to the [GitHub Container Registry](https://github.com/bagashiz/portfolio/pkgs/container/portfolio) automatically.

```yaml
name: Create and publish a Docker image

on:
  push:
    branches: ["main"]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4.1.0

      - name: Log in to the Container registry
        uses: docker/login-action@v3.0.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5.0.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5.0.0
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
```

Finally, I pulled that image and deployed it to my VPS. You can see it live now at [https://bagashiz.me](https://bagashiz.me)!

## Source Code

If you're interested in exploring more about this project, you can check out the source code in my GitHub repository

{% github bagashiz/portfolio %}