Progress UI Elements
Voortgangsbalk (horizontaal) en cirkel variant. Geanimeerd bij load. Label en percentage optioneel.
src/components/ui/Progress.astro
---
/**
* Progress
* Voortgangsbalk + cirkel variant.
* Animated fill, kleur via variant of custom kleur.
*/
interface Props {
value: number; /** 0-100 */
max?: number;
label?: string;
showValue?: boolean;
variant?: 'default' | 'accent' | 'success' | 'warning' | 'danger';
size?: 'xs' | 'sm' | 'md' | 'lg';
type?: 'bar' | 'circle';
strokeWidth?: number; /** voor circle */
animated?: boolean;
}
const {
value,
max = 100,
label,
showValue = false,
variant = 'accent',
size = 'md',
type = 'bar',
strokeWidth = 8,
animated = true,
} = Astro.props;
const pct = Math.min(100, Math.max(0, (value / max) * 100));
// Circle calc
const r = 36;
const circ = 2 * Math.PI * r;
const dash = (pct / 100) * circ;
---
{type === 'circle' ? (
<div class:list={['pr-circle', `pr-circle--${variant}`, `pr-circle--${size}`]} data-component="progress-circle">
<svg viewBox="0 0 88 88" fill="none" aria-valuenow={value} aria-valuemax={max} role="progressbar" aria-label={label}>
<!-- Track -->
<circle cx="44" cy="44" r={r} stroke="rgba(0,0,0,0.07)" stroke-width={strokeWidth} />
<!-- Fill -->
<circle
class:list={['pr-circle__fill', { 'pr-circle__fill--animated': animated }]}
cx="44" cy="44" r={r}
stroke-width={strokeWidth}
stroke-dasharray={`${circ}`}
stroke-dashoffset={animated ? circ : circ - dash}
style={animated ? `--dash:${dash};--circ:${circ}` : ''}
stroke-linecap="round"
transform="rotate(-90 44 44)"
/>
</svg>
<div class="pr-circle__center">
{showValue && <span class="pr-circle__value">{Math.round(pct)}%</span>}
{label && !showValue && <span class="pr-circle__label">{label}</span>}
</div>
</div>
) : (
<div class:list={['pr', `pr--${size}`]} data-component="progress-bar">
{(label || showValue) && (
<div class="pr__meta">
{label && <span class="pr__label">{label}</span>}
{showValue && <span class="pr__value">{Math.round(pct)}%</span>}
</div>
)}
<div
class="pr__track"
role="progressbar"
aria-valuenow={value}
aria-valuemax={max}
aria-label={label}
>
<div
class:list={['pr__fill', `pr__fill--${variant}`, { 'pr__fill--animated': animated }]}
style={`--pct:${pct}%`}
></div>
</div>
</div>
)}
<style>
/* === BAR === */
.pr { display: flex; flex-direction: column; gap: 0.375rem; width: 100%; }
.pr__meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.pr__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-primary, #0a0a0a);
}
.pr__value {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-muted, #6b7280);
font-feature-settings: "tnum";
}
.pr__track {
width: 100%;
background: rgba(0,0,0,0.07);
border-radius: 999px;
overflow: hidden;
}
.pr--xs .pr__track { height: 4px; }
.pr--sm .pr__track { height: 6px; }
.pr--md .pr__track { height: 8px; }
.pr--lg .pr__track { height: 12px; }
.pr__fill {
height: 100%;
border-radius: 999px;
width: var(--pct, 0%);
transition: width 0s;
}
.pr__fill--animated {
width: 0%;
animation: pr-fill 1s 0.2s cubic-bezier(0.22,1,0.36,1) forwards;
}
@keyframes pr-fill {
from { width: 0%; }
to { width: var(--pct); }
}
.pr__fill--accent { background: var(--color-accent, #6366f1); }
.pr__fill--default { background: var(--color-primary, #0a0a0a); }
.pr__fill--success { background: #22c55e; }
.pr__fill--warning { background: #f59e0b; }
.pr__fill--danger { background: #ef4444; }
/* === CIRCLE === */
.pr-circle {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pr-circle--xs { width: 48px; height: 48px; }
.pr-circle--sm { width: 64px; height: 64px; }
.pr-circle--md { width: 88px; height: 88px; }
.pr-circle--lg { width: 120px; height: 120px; }
.pr-circle svg { width: 100%; height: 100%; }
.pr-circle__fill { stroke: var(--color-accent, #6366f1); }
.pr-circle--success .pr-circle__fill { stroke: #22c55e; }
.pr-circle--warning .pr-circle__fill { stroke: #f59e0b; }
.pr-circle--danger .pr-circle__fill { stroke: #ef4444; }
.pr-circle--default .pr-circle__fill { stroke: var(--color-primary, #0a0a0a); }
.pr-circle__fill--animated {
animation: pr-circle 1s 0.2s cubic-bezier(0.22,1,0.36,1) forwards;
}
@keyframes pr-circle {
from { stroke-dashoffset: var(--circ); }
to { stroke-dashoffset: calc(var(--circ) - var(--dash)); }
}
.pr-circle__center {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.125rem;
}
.pr-circle__value {
font-size: 1.125rem;
font-weight: 800;
color: var(--color-primary, #0a0a0a);
line-height: 1;
letter-spacing: -0.03em;
font-feature-settings: "tnum";
}
.pr-circle--sm .pr-circle__value { font-size: 0.875rem; }
.pr-circle--xs .pr-circle__value { font-size: 0.6875rem; }
.pr-circle--lg .pr-circle__value { font-size: 1.5rem; }
.pr-circle__label {
font-size: 0.625rem;
color: var(--color-muted, #6b7280);
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pr__fill--animated { animation: none; width: var(--pct); }
.pr-circle__fill--animated { animation: none; stroke-dashoffset: calc(var(--circ) - var(--dash)); }
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
value * | number | — | Waarde 0-100 |
variant | 'bar' | 'circle' | 'bar' | Weergave variant |
label | string | — | Label boven (bar) of binnen (circle) |
showValue | boolean | false | Toon percentage |
size | 'sm' | 'md' | 'lg' | 'md' | Grootte |
color | string | — | Aangepaste kleur (CSS color) |
* = verplicht