Bar Progress

bar-progress.svelte

A character-based progress bar that fills with a glyph.

Demo

░░░░░░
Usage
001 <script lang="ts">
002 import BarProgress from '$lib/components/bar-progress.svelte';
003 </script>
004
005 <div class="flex flex-col gap-2 w-full">
006 <BarProgress progress={60} />
007 <BarProgress intervalRate={100} fillChar="█" />
008 </div>
009
bar-progress.svelte
001 <script lang="ts" module>
002 /** A character-based progress bar that fills with `fillChar` across its width. */
003 export type BarProgressProps = {
004 /** When omitted, the bar advances itself by 10% every `intervalRate` ms. */
005 intervalRate?: number;
006 /** Set to drive the bar externally (0–100). Disables the auto interval. */
007 progress?: number;
008 /** The character used to fill the bar. Defaults to `░`. */
009 fillChar?: string;
010 };
011 </script>
012
013 <script lang="ts">
014 let { intervalRate, progress, fillChar = '░' }: BarProgressProps = $props();
015
016 // svelte-ignore state_referenced_locally
017 let current = $state(progress ?? 0);
018 let containerWidth = $state(0);
019 let charWidth = $state(0);
020
021 let containerEl = $state<HTMLDivElement>(null!);
022 let measureEl = $state<HTMLSpanElement>(null!);
023
024 // Measure one char so we can compute how many fit across the container.
025 $effect(() => {
026 if (!measureEl) return;
027 const rect = measureEl.getBoundingClientRect();
028 if (rect.width > 0) {
029 charWidth = rect.width;
030 return;
031 }
032 // Font may not be laid out yet — retry next frame.
033 const id = requestAnimationFrame(() => {
034 const r = measureEl?.getBoundingClientRect();
035 if (r && r.width > 0) charWidth = r.width;
036 });
037 return () => cancelAnimationFrame(id);
038 });
039
040 // Track container width via ResizeObserver.
041 $effect(() => {
042 if (!containerEl) return;
043 const obs = new ResizeObserver((entries) => {
044 for (const entry of entries) containerWidth = entry.contentRect.width;
045 });
046 obs.observe(containerEl);
047 return () => obs.disconnect();
048 });
049
050 $effect(() => {
051 if (progress !== undefined) {
052 current = progress;
053 return;
054 }
055 if (!intervalRate) return;
056 const id = setInterval(() => {
057 current = (current + 10) % 110;
058 }, intervalRate);
059 return () => clearInterval(id);
060 });
061
062 const capped = $derived(Math.min(current, 100));
063 const maxChars = $derived(
064 charWidth > 0 && containerWidth > 0 ? Math.max(1, Math.floor(containerWidth / charWidth)) : 10
065 );
066 const bar = $derived(fillChar.repeat(Math.round((capped / 100) * maxChars)));
067 </script>
068
069 <div
070 bind:this={containerEl}
071 class="root"
072 role="progressbar"
073 aria-valuenow={capped}
074 aria-valuemin={0}
075 aria-valuemax={100}
076 >
077 <span bind:this={measureEl} class="measure" aria-hidden="true">{fillChar}</span>
078 {bar}
079 </div>
080
081 <style>
082 .root {
083 display: block;
084 background: var(--border-muted);
085 white-space: nowrap;
086 text-align: left;
087 overflow: hidden;
088 position: relative;
089 }
090
091 .measure {
092 visibility: hidden;
093 position: absolute;
094 pointer-events: none;
095 }
096 </style>
097