diff --git a/.github/workflows/pull_request_checks.yml b/.github/workflows/pull_request_checks.yml
new file mode 100644
index 0000000..ad7b5e4
--- /dev/null
+++ b/.github/workflows/pull_request_checks.yml
@@ -0,0 +1,34 @@
+name: Pull Request Checks
+
+on:
+ pull_request:
+ branches:
+ - main
+
+env:
+ BUN_VERSION: 1.3.6
+
+jobs:
+ ci:
+ name: Lint, Typecheck & Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: ${{ env.BUN_VERSION }}
+ - name: Configure bun to install from GitHub Packages
+ run: rm -rf .npmrc
+ - name: Install dependencies
+ run: bun install
+ env:
+ SCHEMAVAULTS_GITHUB_PACKAGE_REGISTRY_USER: ${{ github.actor }}
+ SCHEMAVAULTS_GITHUB_PACKAGE_REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Lint
+ run: bun run lint
+ - name: Typecheck
+ run: bun run typecheck
+ - name: Build
+ run: bun run build
diff --git a/CLAUDE.md b/CLAUDE.md
index ecdae1b..25154a9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,7 +8,12 @@ This is `@schemavaults/ui`, a React component library package for SchemaVaults f
## Commands
+**Important:** Always run `bun install` first before running any of the commands below. Dependencies may not be installed in a fresh environment, and typecheck/lint/build will fail without them.
+
```bash
+# Install dependencies (run this first)
+bun install
+
# Build the package (compiles TypeScript and resolves path aliases)
bun run build
diff --git a/package.json b/package.json
index f641932..bb8d446 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@schemavaults/ui",
- "version": "0.13.13",
+ "version": "0.13.14",
"private": false,
"license": "UNLICENSED",
"description": "React.js UI components for SchemaVaults frontend applications",
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index b96652e..2da7daa 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -128,3 +128,6 @@ export type * from "./slider";
export * from "./switch";
export type * from "./switch";
+
+export * from "./progress-bar";
+export type * from "./progress-bar";
diff --git a/src/components/ui/progress-bar/ProgressBar.stories.tsx b/src/components/ui/progress-bar/ProgressBar.stories.tsx
new file mode 100644
index 0000000..8a53336
--- /dev/null
+++ b/src/components/ui/progress-bar/ProgressBar.stories.tsx
@@ -0,0 +1,105 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import type { ReactElement } from "react";
+import { LazyFramerMotionProvider } from "@/providers/lazy_framer";
+import { ProgressBar, progressBarSizeIds } from "./progress-bar";
+
+const meta = {
+ title: "Components/ProgressBar",
+ component: ProgressBar,
+ parameters: {
+ layout: "centered",
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ value: {
+ control: {
+ type: "range",
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ },
+ size: {
+ options: progressBarSizeIds,
+ control: {
+ type: "radio",
+ },
+ },
+ min: {
+ control: {
+ type: "number",
+ },
+ },
+ max: {
+ control: {
+ type: "number",
+ },
+ },
+ },
+ args: {
+ value: 50,
+ label: "Progress",
+ min: 0,
+ max: 100,
+ },
+ decorators: [
+ (Story): ReactElement => {
+ return (
+
+
+
+
+
+ );
+ },
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ value: 50,
+ label: "Upload progress",
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ value: 0,
+ label: "Upload progress",
+ },
+};
+
+export const Full: Story = {
+ args: {
+ value: 100,
+ label: "Upload progress",
+ },
+};
+
+export const Small: Story = {
+ args: {
+ value: 65,
+ label: "Upload progress",
+ size: "sm",
+ },
+};
+
+export const Large: Story = {
+ args: {
+ value: 75,
+ label: "Upload progress",
+ size: "lg",
+ },
+};
+
+export const CustomRange: Story = {
+ args: {
+ value: 7,
+ label: "Steps completed",
+ min: 0,
+ max: 10,
+ },
+};
diff --git a/src/components/ui/progress-bar/index.ts b/src/components/ui/progress-bar/index.ts
new file mode 100644
index 0000000..15979cd
--- /dev/null
+++ b/src/components/ui/progress-bar/index.ts
@@ -0,0 +1,3 @@
+export { ProgressBar, progressBarVariants, progressBarSizeIds } from "./progress-bar";
+export type * from "./progress-bar";
+export { ProgressBar as default } from "./progress-bar";
diff --git a/src/components/ui/progress-bar/progress-bar.tsx b/src/components/ui/progress-bar/progress-bar.tsx
new file mode 100644
index 0000000..9718199
--- /dev/null
+++ b/src/components/ui/progress-bar/progress-bar.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { cva, type VariantProps } from "class-variance-authority";
+import { m } from "@/framer-motion";
+import { cn } from "@/lib/utils";
+import type { ReactElement, HTMLAttributes } from "react";
+
+export const progressBarVariants = cva(
+ "relative w-full overflow-hidden rounded-full bg-secondary",
+ {
+ variants: {
+ size: {
+ sm: "h-2",
+ default: "h-3",
+ lg: "h-5",
+ },
+ },
+ defaultVariants: {
+ size: "default",
+ },
+ },
+);
+
+export const progressBarSizeIds = ["sm", "default", "lg"] as const;
+
+export type ProgressBarSizeId = (typeof progressBarSizeIds)[number];
+
+export interface ProgressBarProps
+ extends Omit, "role">,
+ VariantProps {
+ /** Current progress value (0-100) */
+ value: number;
+ /** Accessible label describing what the progress bar represents */
+ label: string;
+ /** Minimum value (defaults to 0) */
+ min?: number;
+ /** Maximum value (defaults to 100) */
+ max?: number;
+ /** Additional classes for the filled indicator */
+ indicatorClassName?: string;
+}
+
+export function ProgressBar({
+ value,
+ label,
+ min = 0,
+ max = 100,
+ size,
+ className,
+ indicatorClassName,
+ ...props
+}: ProgressBarProps): ReactElement {
+ const clampedValue: number = Math.min(max, Math.max(min, value));
+ const percentage: number = ((clampedValue - min) / (max - min)) * 100;
+
+ return (
+
+
+
+ );
+}
+
+ProgressBar.displayName = "ProgressBar";