Card

card.svelte

Container components used to group related content with optional titles.

Demo

LEFT ALIGNED

Card content with left-aligned title (default).

RIGHT ALIGNED

Card content with right-aligned title.

Card without a title.

Usage
001 <script lang="ts">
002 import Card from '$lib/components/card.svelte';
003 </script>
004
005 <div class="flex flex-col gap-4">
006 <Card title="LEFT ALIGNED">
007 <p>Card content with left-aligned title (default).</p>
008 </Card>
009
010 <Card title="RIGHT ALIGNED" alignTitle="right">
011 <p>Card content with right-aligned title.</p>
012 </Card>
013
014 <Card>
015 <p>Card without a title.</p>
016 </Card>
017 </div>
018
card.svelte
001 <script lang="ts" module>
002 import type { HTMLAttributes } from 'svelte/elements';
003
004 export type CardProps = HTMLAttributes<HTMLElement> & {
005 ref?: HTMLElement | null;
006 title?: string;
007 alignTitle?: 'left' | 'right';
008 };
009 </script>
010
011 <script lang="ts">
012 let {
013 class: className = '',
014 ref = $bindable(null),
015 title = '',
016 alignTitle = 'left',
017 children,
018 ...restProps
019 }: CardProps = $props();
020 </script>
021
022 {#snippet titleElement()}
023 <header class="action">
024 {#if alignTitle === 'left'}
025 <div class="leftCorner" aria-hidden="true"></div>
026 {:else}
027 <div class="left" aria-hidden="true"></div>
028 {/if}
029 {#if title}<h2 class="title">{title}</h2>{/if}
030 {#if alignTitle === 'right'}
031 <div class="rightCorner" aria-hidden="true"></div>
032 {:else}
033 <div class="right" aria-hidden="true"></div>
034 {/if}
035 </header>
036 {/snippet}
037
038 <article class="card {className}" bind:this={ref} {...restProps}>
039 {@render titleElement()}
040 <section class="children">{@render children?.()}</section>
041 </article>
042
043 <style lang="postcss">
044 .card {
045 position: relative;
046 display: block;
047 padding: 0;
048 }
049
050 /*
051 The header pieces and the body both paint their borders with inset
052 box-shadows (no CSS border). Same primitive on both sides of the seam
053 means the 2px lines land in the same place and stay continuous.
054 */
055 .children {
056 box-shadow:
057 inset 2px 0 0 0 var(--text-primary),
058 inset -2px 0 0 0 var(--text-primary),
059 inset 0 -2px 0 0 var(--text-primary);
060 display: block;
061 padding-top: var(--line);
062 padding-left: 2ch;
063 padding-right: 2ch;
064 padding-bottom: var(--line);
065 overflow-x: auto;
066 overflow-y: hidden;
067 margin: 0 !important;
068
069 &::-webkit-scrollbar {
070 display: none;
071 }
072
073 scrollbar-width: none;
074 }
075
076 .action {
077 display: flex;
078 align-items: flex-end;
079 justify-content: space-between;
080 }
081
082 .left {
083 min-width: 10%;
084 width: 100%;
085 box-shadow:
086 inset 2px 0 0 0 var(--text-primary),
087 inset 0 2px 0 0 var(--text-primary);
088 padding: var(--line) 2ch 0px 1ch;
089 }
090
091 .leftCorner {
092 flex-shrink: 0;
093 box-shadow:
094 inset 2px 0 0 0 var(--text-primary),
095 inset 0 2px 0 0 var(--text-primary);
096 padding: var(--line) 1ch 0px 1ch;
097 }
098
099 .right {
100 min-width: 10%;
101 width: 100%;
102 box-shadow:
103 inset -2px 0 0 0 var(--text-primary),
104 inset 0 2px 0 0 var(--text-primary);
105 padding: var(--line) 2ch 0px 1ch;
106 }
107
108 .rightCorner {
109 flex-shrink: 0;
110 box-shadow:
111 inset -2px 0 0 0 var(--text-primary),
112 inset 0 2px 0 0 var(--text-primary);
113 padding: var(--line) 1ch 0px 1ch;
114 }
115
116 .title {
117 font-size: inherit;
118 font-weight: inherit;
119 line-height: inherit;
120 margin: 0;
121 padding: 0 1ch;
122 flex-shrink: 0;
123 }
124 </style>
125