diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index a025d84b6599988d21596e4ec1fd8110e3b27370..2787092ac713d7d89090ee1fd03c4cb00b469d45 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -24,6 +24,7 @@ export default defineConfig({
             { text: "Radio Card", link: "/components/card/radio" },
             { text: "Artist Card", link: "/components/card/artist" },
             { text: "Album Card", link: "/components/card/album" },
+            { text: "Playlist Card", link: "/components/card/playlist" },
           ] },
         ]
       }
diff --git a/docs/components/card/playlist.md b/docs/components/card/playlist.md
new file mode 100644
index 0000000000000000000000000000000000000000..5bc64d5da9b42a105d2b78ab0ee312a465fc4644
--- /dev/null
+++ b/docs/components/card/playlist.md
@@ -0,0 +1,25 @@
+<script setup lang="ts">
+const playlist = {
+  name: 'Incredible Playlist',
+  user: {
+    full_username: '@username:example.com',
+    username: '@username'
+  },
+  tracks_count: 27,
+  album_covers: [
+    'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
+    'https://unsplash.com/photos/SVGan4GCopM/download?ixid=MnwxMjA3fDB8MXxzZWFyY2h8MTV8fG11c2ljaWFufGVufDB8Mnx8fDE2NjI5NzQwNjI&force=true&w=640',
+    'https://unsplash.com/photos/P4gXe-RsEXI/download?ixid=MnwxMjA3fDB8MXxzZWFyY2h8Nnx8cGVyZm9ybWVyfGVufDB8Mnx8fDE2NjMwMTM3OTE&force=true&w=640'
+  ]
+}
+</script>
+
+# Playlist card
+## Normal card
+```html
+<fw-playlist-card :playlist="playlist" />
+```
+<div style="display: grid; grid-template-columns: auto auto 1fr; grid-gap: 2rem;">
+<fw-playlist-card :playlist="playlist" />
+<fw-playlist-card :playlist="{ ...playlist, album_covers: [] }" />
+</div>
diff --git a/src/components/card/Card.vue b/src/components/card/Card.vue
index 98ca5a9be4c6aa92dff6cc77e1720d0fa2734d7b..7f1306bb288069681eed0cd3d49794a444d69001 100644
--- a/src/components/card/Card.vue
+++ b/src/components/card/Card.vue
@@ -16,13 +16,21 @@ defineProps<Props>()
 	<div
     @click="(event) => !buttonTitle ? onClick?.(event) : undefined"
     class="funkwhale card"
-    :class="{ 'has-image': !!image, 'is-link': !!onClick, 'is-category': category, 'is-cta': !!buttonTitle }"
+    :class="{ 'has-image': !!image || $slots.image, 'is-link': !!onClick, 'is-category': category, 'is-cta': !!buttonTitle }"
   >
+    <div
+      v-if="$slots.image"
+      class="card-image"
+    >
+      <slot name="image" :src="image" />
+    </div>
+
     <img
-      v-if="image"
+      v-else-if="image"
       :src="image"
       class="card-image"
     />
+
     <div class="card-title">{{ title }}</div>
     <div
       v-if="$slots.default"
diff --git a/src/components/card/playlist/Card.vue b/src/components/card/playlist/Card.vue
new file mode 100644
index 0000000000000000000000000000000000000000..48f7f10e876b740faa4519eade088bcd9ab6217f
--- /dev/null
+++ b/src/components/card/playlist/Card.vue
@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import { FwCard, FwPlayButton, FwOptionsButton } from '~/components'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { computed } from 'vue'
+
+const { t } = useI18n()
+
+interface Playlist {
+  id: number
+  name: string
+  user: {
+    full_username: string
+    username: string
+  }
+  tracks_count: number
+  album_covers: string[]
+}
+
+interface Events {
+  (e: 'play', playlist: Playlist): void
+}
+
+interface Props {
+  playlist: Playlist
+}
+
+const emit = defineEmits<Events>()
+const props = defineProps<Props>()
+
+const covers = computed(() => props.playlist.album_covers
+  .filter((src, index, array) => array.indexOf(src) === index)
+  .slice(0, 4)
+)
+
+const profileParams = computed(() => {
+  const [username, domain] = props.playlist.user.full_username.split('@')
+  return { username, domain }
+})
+
+let navigate = (to: 'playlist' | 'user') => {}
+
+if (import.meta.env.PROD) {
+  const router = useRouter()
+  navigate = (to: 'playlist' | 'user') => to === 'playlist'
+    ? router.push({ name: 'library.playlists.detail', params: { id: props.playlist.id } })
+    : router.push({ name: 'profile.full', params: profileParams.value })
+}
+</script>
+
+<template>
+  <fw-card
+    :title="playlist.name"
+    @click="navigate('playlist')"
+    class="playlist-card"
+  >
+    <template #image>
+      <img
+        v-for="src in covers"
+        :key="src"
+        :src="src"
+      />
+      <div
+        v-for="i in Math.max(0, 4 - covers.length)"
+        :key="i"
+      />
+    </template>
+
+    <fw-play-button @play="emit('play', playlist)" />
+
+    <a
+      @click.stop="navigate('user')"
+      class="funkwhale link artist-link"
+    >
+      {{ t('vui.playlist-by', playlist.user) }}
+    </a>
+
+    <div class="card-footer">
+      {{ t('vui.tracks', playlist.tracks_count) }}
+      <fw-options-button />
+    </div>
+  </fw-card>
+</template>
+
+<style lang="scss">
+@import './style.scss'
+</style>
diff --git a/src/components/card/playlist/style.scss b/src/components/card/playlist/style.scss
new file mode 100644
index 0000000000000000000000000000000000000000..7c59cad0f372d2bc43b04ebacaf6c5b4a4f38197
--- /dev/null
+++ b/src/components/card/playlist/style.scss
@@ -0,0 +1,91 @@
+.funkwhale {
+  &.card.playlist-card {
+    --fw-card-width: 208px;
+
+    position: relative;
+    width: var(--fw-card-width);
+    padding-bottom: 14px;
+
+    &:hover {
+      .play-button {
+        opacity: 1 !important;
+        transform: translateX(6px) !important;
+      }
+
+      .options-button {
+        opacity: 1 !important;
+        transform: translate(12px) !important;
+      }
+    }
+
+    .play-button,
+    .options-button {
+      opacity: 0;
+    }
+
+    .options-button {
+      transform: translateX(6px) !important;
+      transition-delay: .1s;
+    }
+
+    --fw-image-width: var(--fw-card-width);
+    > .card-image {
+      border-radius: 0 !important;
+      height: var(--fw-image-width);
+      width: var(--fw-image-width);
+      max-width: var(--fw-image-width);
+      margin: -24px -24px 0;
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      grid-template-rows: 1fr 1fr;
+
+      > div {
+        --fw-bg-color: var(--fw-pastel-blue-3);
+        background-color: var(--fw-bg-color);
+        &:nth-child(2) {
+          --fw-bg-color: var(--fw-pastel-blue-1);
+        }
+        &:nth-child(3) {
+          --fw-bg-color: var(--fw-pastel-blue-2);
+        }
+        &:nth-child(4) {
+          --fw-bg-color: var(--fw-pastel-blue-4);
+        }
+      }
+    }
+
+    > .card-title {
+      font-size: 1rem;
+      text-align: left !important;
+      padding-top: 16px !important;
+    }
+
+    > .card-content {
+      padding-top: 0 !important;
+      text-align: left !important;
+
+      > .artist-link {
+        font-size: 0.875rem;
+        --fw-link-color: var(--fw-text-color);
+      }
+
+      > .play-button {
+        position: absolute;
+        right: 8px;
+        top: calc(var(--fw-image-width) - 8px - 44px);
+      }
+
+      > .card-footer {
+        display: flex;
+        align-items: center;
+        color: var(--fw-grey-700);
+        font-size: 0.8125rem;
+        padding-top: 6px;
+
+        > .options-button {
+          margin-left: auto;
+        }
+      }
+    }
+  }
+}
diff --git a/src/components/card/style.scss b/src/components/card/style.scss
index fbeb82025d57a18f6edb31fb06b07bcb9c89a08c..5796a20471d0b868ba9b8b69471a91d960960d5b 100644
--- a/src/components/card/style.scss
+++ b/src/components/card/style.scss
@@ -12,7 +12,8 @@
       cursor: pointer;
     }
 
-    > img.card-image {
+    > .card-image {
+      overflow: hidden;
       border-radius: var(--fw-border-radius);
       object-fit: cover;
       height: 160px;
diff --git a/src/components/index.ts b/src/components/index.ts
index cf0bcd1f4ca61d61fc9bb2bec16dbe63a0a812c1..8504a645cb2bc40ecee82aa193fd04c4e73c0902 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,11 +1,17 @@
+// Buttons
 export { default as FwOptionsButton } from './button/options/Button.vue'
 export { default as FwPlayButton } from './button/play/Button.vue'
 export { default as FwButton } from './button/Button.vue'
 
+// Cards
+export { default as FwPlaylistCard } from './card/playlist/Card.vue'
 export { default as FwArtistCard } from './card/artist/Card.vue'
 export { default as FwAlbumCard } from './card/album/Card.vue'
 export { default as FwRadioCard } from './card/radio/Card.vue'
 export { default as FwCard } from './card/Card.vue'
 
-export { default as FwLoader } from './loader/Loader.vue'
+// Pills
 export { default as FwPill } from './pill/Pill.vue'
+
+// Loader
+export { default as FwLoader } from './loader/Loader.vue'
diff --git a/src/locales/en.yaml b/src/locales/en.yaml
index 4e46204a9ad5dc1032e729282e5018f2e612e479..84ae584ce3512b181a2d76582ac0d5c255f6b76b 100644
--- a/src/locales/en.yaml
+++ b/src/locales/en.yaml
@@ -2,3 +2,4 @@ vui:
   radio: Radio
   albums: '{n} album | {n} albums'
   tracks: '{n} track | {n} tracks'
+  playlist-by: 'by {username}'
diff --git a/src/styles/base/generic.scss b/src/styles/base/generic.scss
index 4948b6b615d7d69fb06e157af35f728743034a28..0102dfd3ef7767f3135710dcac01b0568195fe05 100644
--- a/src/styles/base/generic.scss
+++ b/src/styles/base/generic.scss
@@ -3,7 +3,7 @@
 a {
   --fw-link-color: var(--fw-blue-500);
 
-  &:not(.VPLink):not(.vp-doc .header-anchor):not(.VPDocAsideOutline .outline-link):not(.VPNavBarTitle .title) {
+  &:not(.VPLink):not(.vp-doc .header-anchor):not(.VPDocAsideOutline .outline-link):not(.VPNavBarTitle .title):not(.VPDocFooter .pager-link) {
     color: var(--fw-link-color);
 
     &:hover,