DEV Community

Cover image for Crafting A Minimalist Portfolio Website with SvelteKit and Pico CSS
Bagas Hizbullah
Bagas Hizbullah

Posted on

Crafting A Minimalist Portfolio Website with SvelteKit and Pico CSS

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 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, and its meta-framework, SvelteKit.

The simplicity of Svelte's syntax, its templates, and its approach to frontend development, which relies on compilation rather than a virtual DOM, 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, 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, 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:



npm create svelte@latest my-app


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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



<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>


Enter fullscreen mode Exit fullscreen mode

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



/**
 * 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 = [
// ...
];


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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:



npm install -D @picocss/pico


Enter fullscreen mode Exit fullscreen mode

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



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


Enter fullscreen mode Exit fullscreen mode

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.



/*!
 * 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);
        }
    }
};


Enter fullscreen mode Exit fullscreen mode

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



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

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


Enter fullscreen mode Exit fullscreen mode

Font Awesome Icons

Utilizing Font Awesome icons in my project was easy. By integrating the Font Awesome 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. I included a username query parameter with my username as the value:



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


Enter fullscreen mode Exit fullscreen mode

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.



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);
                    });
                }
            })
        }
    };
}


Enter fullscreen mode Exit fullscreen mode

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



<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>


Enter fullscreen mode Exit fullscreen mode

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. 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:



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


Enter fullscreen mode Exit fullscreen mode

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.



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);
                        });
                }
            })
        }
    };
}


Enter fullscreen mode Exit fullscreen mode

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 package, which is a popular redis client for Node.js for interacting with a Redis database.



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;


Enter fullscreen mode Exit fullscreen mode

For caching strategy, I implemented the 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.



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);
                        });
                }
            });
        })
    }
};
// ...


Enter fullscreen mode Exit fullscreen mode


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);
                        });
                }
            });
        })
    }
};
// ...


Enter fullscreen mode Exit fullscreen mode

Trying Out New View Transitions API

The view transitions API  streamlines the process of animating between two page states, which is especially useful for page transitions. This excellent blog post by Geoff Rich 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.



<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>


Enter fullscreen mode Exit fullscreen mode

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



@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;
    }
}


Enter fullscreen mode Exit fullscreen mode

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.

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.



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"]


Enter fullscreen mode Exit fullscreen mode

After that, I set up a GitHub workflow to build and publish the Docker image to the GitHub Container Registry automatically.



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 }}


Enter fullscreen mode Exit fullscreen mode

Finally, I pulled that image and deployed it to my VPS. You can see it live now at 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 logo bagashiz / portfolio

Class-less personal portfolio & resume website built using Go, Templ, HTMX, and styled with Pico CSS.

Portfolio

Description

My personal portfolio website for showcasing my resume, projects, and blog posts. The site is built using Go, Templ, HTMX, and Pico CSS. The site also uses KeyDB as a drop-in replacement for Redis.

The featured GitHub projects are dynamically retrieved through the power of the GitHub GraphQL API. The blog posts are seamlessly pulled in using the Dev.to API. Additionally, KeyDB is used to cache the GitHub and Dev.to API responses for 1 hour to reduce the number of API calls. Icons are provided by Font Awesome through their kit from the CDN. I've also implemented the new View Transition API feature to enhance the user experience.

There is an old stack version of the site in the sveltekit branch. The old stack version of the site was built using SveteKit.

Demo

Check out the live demo at bagashiz.xyz!

Dependencies

  • Go version 1.22 or…




Top comments (0)