Progress / Meter
Progress bars and meters sized from --bit-bh-progress / --bit-bh-meter with arbitrary-value utilities.
Determinate progress
role="progressbar" with value attributes and a percentage aria-valuetext.
--bit-bh-progress is a unitless 0–100 number: w-[calc(var(--bit-bh-progress,0)*1%)] sizes the indicator, data-[state=complete]: recolors it.
<div class="grid w-full max-w-sm gap-2.5 rounded-xl border border-zinc-200 bg-white p-4">
<div class="flex items-baseline justify-between gap-4 text-sm">
<span class="font-semibold text-zinc-900" id="tw-profile-label">Profile completion</span>
<span class="text-xs tabular-nums text-zinc-500">@profile%</span>
</div>
<BitBhProgress Value="profile" aria-labelledby="tw-profile-label"
class="relative h-2.5 w-full overflow-hidden rounded-full bg-zinc-200">
<BitBhProgressIndicator class="h-full w-[calc(var(--bit-bh-progress,0)*1%)] rounded-full bg-blue-600 transition-all duration-300 data-[state=complete]:bg-green-600" />
</BitBhProgress>
<div class="flex gap-2">
<button type="button" @onclick="() => profile = Math.Max(0, profile - 20)"
class="inline-flex cursor-pointer items-center rounded-md border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 outline-none hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2">− 20%</button>
<button type="button" @onclick="() => profile = Math.Min(100, profile + 20)"
class="inline-flex cursor-pointer items-center rounded-md border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 outline-none hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2">+ 20%</button>
</div>
</div>
@code {
private double profile = 60;
}Simulated upload
A timer drives Value in MB against Max="8"; ValueText reads "2.4 of 8 MB uploaded".
It starts indeterminate (Value="null") while preparing. Template receives the percentage for the in-bar label; the page disposes the timer.
@implements IDisposable
<div class="grid w-full max-w-sm gap-2.5 rounded-xl border border-zinc-200 bg-white p-4">
<div class="flex items-baseline justify-between gap-4 text-sm">
<span class="font-semibold text-zinc-900" id="tw-upload-label">quarterly-report.pdf</span>
<span class="text-xs tabular-nums text-zinc-500">@UploadStatus()</span>
</div>
<BitBhProgress Value="uploaded" Max="FileSizeMb"
ValueText="@((v, max) => $"{v:0.0} of {max:0} MB uploaded")"
aria-labelledby="tw-upload-label"
class="relative h-5 w-full overflow-hidden rounded-full bg-zinc-200">
<Template Context="percent">
<BitBhProgressIndicator class="h-full w-[calc(var(--bit-bh-progress,0)*1%)] rounded-full bg-blue-200 transition-all duration-300 data-[state=complete]:bg-green-200 data-[state=indeterminate]:w-full data-[state=indeterminate]:motion-safe:animate-pulse" />
<span class="absolute inset-0 flex items-center justify-end px-2.5 text-[0.72rem] font-semibold tabular-nums text-blue-900">@(percent is null ? "" : $"{percent:0}%")</span>
</Template>
</BitBhProgress>
<div class="flex gap-2">
<button type="button" disabled="@uploading" @onclick="StartUpload"
class="inline-flex cursor-pointer items-center rounded-md border border-blue-600 bg-blue-600 px-3 py-1.5 text-xs font-medium text-white outline-none hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
@(uploaded >= FileSizeMb ? "Upload again" : "Start upload")
</button>
<button type="button" disabled="@(!uploading)" @onclick="CancelUpload"
class="inline-flex cursor-pointer items-center rounded-md border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 outline-none hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">Cancel</button>
</div>
</div>
@code {
private const double FileSizeMb = 8;
private double? uploaded = 0; // null = indeterminate
private bool uploading;
private int uploadTicks;
private System.Threading.Timer? uploadTimer;
private string UploadStatus()
{
if (uploaded is null) return "Preparing…";
if (uploaded >= FileSizeMb) return "Complete";
if (!uploading) return "Ready";
return $"{uploaded:0.0} of {FileSizeMb:0} MB";
}
private void StartUpload()
{
uploadTimer?.Dispose();
uploading = true;
uploaded = null; // indeterminate while "preparing"
uploadTicks = 0;
uploadTimer = new System.Threading.Timer(_ => InvokeAsync(UploadTick), null, 150, 150);
}
private void UploadTick()
{
if (!uploading) return;
uploadTicks++;
if (uploadTicks > 6) // ~1 s of "preparing" first
{
uploaded = Math.Min(FileSizeMb, (uploaded ?? 0) + 0.2);
if (uploaded >= FileSizeMb) StopTimer();
}
StateHasChanged();
}
private void CancelUpload()
{
StopTimer();
uploaded = 0;
}
private void StopTimer()
{
uploading = false;
uploadTimer?.Dispose();
uploadTimer = null;
}
public void Dispose() => uploadTimer?.Dispose();
}Indeterminate
Leave Value unset: aria-valuenow is omitted and data-state="indeterminate" is set — style it with data-[state=indeterminate]: (gate the animation with motion-safe:).
<div class="grid w-full max-w-sm gap-2.5 rounded-xl border border-zinc-200 bg-white p-4">
<div class="flex items-baseline justify-between gap-4 text-sm">
<span class="font-semibold text-zinc-900" id="tw-sync-label">Syncing mailbox</span>
<span class="text-xs text-zinc-500">Waiting for the server…</span>
</div>
@* No Value: indeterminate *@
<BitBhProgress aria-labelledby="tw-sync-label"
class="relative h-2.5 w-full overflow-hidden rounded-full bg-zinc-200">
<BitBhProgressIndicator class="h-full w-[calc(var(--bit-bh-progress,0)*1%)] rounded-full bg-blue-600 data-[state=indeterminate]:w-full data-[state=indeterminate]:bg-blue-400 data-[state=indeterminate]:motion-safe:animate-pulse" />
</BitBhProgress>
</div>Meter: disk usage
role="meter" for a measurement in a known range. Low / High produce data-level; mark the meter as a group and color the fill with group-data-[level=high]:.
- System 231 of 256 GB
- Projects 118 of 256 GB
- Backup 37 of 256 GB
<ul class="m-0 grid w-full max-w-sm list-none gap-4 p-0">
@foreach (var drive in drives)
{
<li class="grid gap-1.5">
<div class="flex items-baseline justify-between gap-4 text-sm">
<span class="font-semibold text-zinc-900" id="@($"tw-drive-{drive.Id}")">@drive.Name</span>
<span class="text-xs tabular-nums text-zinc-500">@drive.Used of 256 GB</span>
</div>
<BitBhMeter Value="drive.Used" Max="256" Low="64" High="200"
ValueText="@(v => $"{v} of 256 GB used")"
aria-labelledby="@($"tw-drive-{drive.Id}")"
class="group relative h-2 w-full overflow-hidden rounded-full bg-zinc-200">
<span class="block h-full w-[calc(var(--bit-bh-meter)*1%)] rounded-full bg-blue-600 transition-all duration-300 group-data-[level=low]:bg-green-600 group-data-[level=high]:bg-red-600"></span>
</BitBhMeter>
</li>
}
</ul>
@code {
private readonly (string Id, string Name, double Used)[] drives =
[("system", "System", 231), ("projects", "Projects", 118), ("backup", "Backup", 37)];
}Meter: password strength
A 0–4 score with Low="2" and High="3". The meter's Template receives the percentage and fills four segments, colored by level.
<div class="grid w-full max-w-xs gap-2">
<label for="tw-pw" class="text-sm font-semibold text-zinc-900">Choose a password</label>
<input id="tw-pw" type="password" autocomplete="new-password"
@bind="password" @bind:event="oninput" aria-describedby="tw-pw-strength"
class="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-600" />
<BitBhMeter Value="Score()" Max="4" Low="2" High="3"
ValueText="@(v => StrengthLabels[(int)v])"
aria-label="Password strength"
class="group grid grid-cols-4 gap-1">
<Template Context="percent">
@for (var i = 1; i <= 4; i++)
{
<span data-filled="@(percent >= i * 25)"
class="h-1.5 rounded-full bg-zinc-200 transition-colors group-data-[level=low]:data-filled:bg-red-600 group-data-[level=optimum]:data-filled:bg-amber-600 group-data-[level=high]:data-filled:bg-green-600"></span>
}
</Template>
</BitBhMeter>
<span class="text-xs text-zinc-500" id="tw-pw-strength">Strength: <strong class="font-semibold text-zinc-900">@(StrengthLabels[Score()])</strong></span>
</div>
@code {
private string password = "";
private static readonly string[] StrengthLabels = ["Very weak", "Weak", "Fair", "Good", "Strong"];
private int Score()
{
if (password.Length == 0) return 0;
var score = 0;
if (password.Length >= 8) score++;
if (password.Any(char.IsUpper) && password.Any(char.IsLower)) score++;
if (password.Any(char.IsDigit)) score++;
if (password.Any(c => !char.IsLetterOrDigit(c))) score++;
return score;
}
}