Skip to content
Back to skills

Version Badge Pattern

ASecurity

Implement a version badge UI component showing build version, git commit, and recent changelog in a tooltip. Use when adding version visibility to applications for support, debugging, and change awareness. Works with React, Vue, Svelte, and plain JavaScript.

  • 58 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added February 8, 2026
developmentjavascripttypescriptjavabashreactvuenextjsnodedebugginggit

Works with

  • cursor
  • cli

Security analysis

A100/100

Scanned February 12, 2026

npx -y skills add laurigates/claude-plugins --skill version-badge-pattern --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Version Badge Pattern?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Version Badge Pattern
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/laurigates-version-badge-pattern/badge)](https://www.skillsdirectory.com/skills/laurigates-version-badge-pattern)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
model: haiku
name: Version Badge Pattern
description: |
  Implement a version badge UI component showing build version, git commit, and
  recent changelog in a tooltip. Use when adding version visibility to applications
  for support, debugging, and change awareness. Works with React, Vue, Svelte, and
  plain JavaScript.
allowed-tools: Bash, Read, Write, Edit, Grep, Glob, TodoWrite
created: 2025-02-03
modified: 2025-02-03
reviewed: 2026-02-08
---

# Version Badge Pattern

A reusable UI pattern for displaying application version with build metadata and recent changes.

## When to Use This Skill

| Use this skill when... | Use alternative when... |
|------------------------|------------------------|
| Adding version display to app header/footer | Just need version in package.json |
| Want tooltip with changelog info | Only need static version text |
| Need accessible, keyboard-navigable version info | Building a non-interactive display |
| Implementing across React/Vue/Svelte | Using server-rendered only (no JS) |

## Pattern Overview

```
┌──────────────────────────────────────┐
│  App Header              v1.43.0|004ddd9  ← Trigger (always visible)
└──────────────────────────────────────┘
                                 │
                                 ▼ (on hover/focus)
                    ┌─────────────────────────┐
                    │ Build Information       │
                    │ Version: 1.43.0         │
                    │ Commit:  004ddd97e8...  │
                    │ Built:   Dec 11, 10:00  │
                    │ Branch:  main           │
                    │─────────────────────────│
                    │ Recent Changes          │
                    │ v1.43.0                 │
                    │ ✨ New feature X        │
                    │ 🐛 Fixed bug Y          │
                    │ v1.42.0                 │
                    │ ⚡ Improved perf Z      │
                    └─────────────────────────┘
```

## Data Flow

```
CHANGELOG.md → parse-changelog.mjs → ENV_VAR → Component
package.json version ─────────────────────┘
git commit SHA ───────────────────────────┘
```

## Build Script

Create `scripts/parse-changelog.mjs`:

```javascript
#!/usr/bin/env node
/**
 * parse-changelog.mjs
 * Parses CHANGELOG.md for version badge tooltip
 *
 * Output: JSON array of versions with their changes
 * Usage: node scripts/parse-changelog.mjs
 */

import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const CHANGELOG_PATH = join(__dirname, '..', 'CHANGELOG.md');

const MAX_VERSIONS = 2;
const MAX_FEATURES = 3;
const MAX_OTHER = 2;

const CHANGE_TYPES = {
  feat: { icon: 'sparkles', label: 'Feature' },
  fix: { icon: 'bug', label: 'Bug Fix' },
  perf: { icon: 'zap', label: 'Performance' },
  breaking: { icon: 'warning', label: 'Breaking' },
  refactor: { icon: 'recycle', label: 'Refactor' },
  docs: { icon: 'book', label: 'Documentation' },
};

function parseChangelog() {
  if (!existsSync(CHANGELOG_PATH)) {
    console.log(JSON.stringify([]));
    return;
  }

  const content = readFileSync(CHANGELOG_PATH, 'utf-8');
  const lines = content.split('\n');

  const versions = [];
  let currentVersion = null;

  for (const line of lines) {
    // Match version header: ## [1.43.0] or ## 1.43.0
    const versionMatch = line.match(/^## \[?(\d+\.\d+\.\d+)\]?/);
    if (versionMatch) {
      if (currentVersion) {
        versions.push(currentVersion);
      }
      if (versions.length >= MAX_VERSIONS) break;

      currentVersion = {
        version: versionMatch[1],
        features: [],
        fixes: [],
        other: [],
      };
      continue;
    }

    if (!currentVersion) continue;

    // Match change entries: * **type:** description or * **type**: description
    const changeMatch = line.match(/^\* \*\*(\w+):\*?\*? (.+)$/);
    if (changeMatch) {
      const [, type, description] = changeMatch;
      const changeType = CHANGE_TYPES[type.toLowerCase()] || CHANGE_TYPES.refactor;

      const entry = {
        type: type.toLowerCase(),
        icon: changeType.icon,
        description: description.trim(),
      };

      if (type.toLowerCase() === 'feat' && currentVersion.features.length < MAX_FEATURES) {
        currentVersion.features.push(entry);
      } else if (type.toLowerCase() === 'fix' && currentVersion.fixes.length < MAX_OTHER) {
        currentVersion.fixes.push(entry);
      } else if (currentVersion.other.length < MAX_OTHER) {
        currentVersion.other.push(entry);
      }
    }
  }

  if (currentVersion) {
    versions.push(currentVersion);
  }

  console.log(JSON.stringify(versions.slice(0, MAX_VERSIONS)));
}

parseChangelog();
```

## React + Tailwind + shadcn/ui Implementation

### Component: `components/version-badge.tsx`

```tsx
'use client';

import { useMemo } from 'react';
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';

interface BuildInfo {
  version: string;
  commit: string;
  branch: string;
  buildTime: string;
}

interface ChangeEntry {
  type: string;
  icon: string;
  description: string;
}

interface VersionEntry {
  version: string;
  features: ChangeEntry[];
  fixes: ChangeEntry[];
  other: ChangeEntry[];
}

const ICON_MAP: Record<string, string> = {
  sparkles: '✨',
  bug: '🐛',
  zap: '⚡',
  warning: '⚠️',
  recycle: '♻️',
  book: '📖',
};

function getIcon(iconName: string): string {
  return ICON_MAP[iconName] || '•';
}

export function VersionBadge() {
  const buildInfo = useMemo<BuildInfo | null>(() => {
    try {
      const raw = process.env.NEXT_PUBLIC_BUILD_INFO;
      return raw ? JSON.parse(raw) : null;
    } catch {
      return null;
    }
  }, []);

  const changelog = useMemo<VersionEntry[]>(() => {
    try {
      const raw = process.env.NEXT_PUBLIC_CHANGELOG;
      return raw ? JSON.parse(raw) : [];
    } catch {
      return [];
    }
  }, []);

  // Hide in development when no build info
  if (!buildInfo?.version || buildInfo.version === 'dev') {
    return null;
  }

  const shortCommit = buildInfo.commit?.slice(0, 7) || 'unknown';
  const formattedDate = buildInfo.buildTime
    ? new Date(buildInfo.buildTime).toLocaleString('en-US', {
        month: 'short',
        day: 'numeric',
        year: 'numeric',
        hour: 'numeric',
        minute: '2-digit',
        timeZoneName: 'short',
      })
    : 'Unknown';

  return (
    <TooltipProvider>
      <Tooltip delayDuration={300}>
        <TooltipTrigger asChild>
          <button
            className={cn(
              'text-[10px] text-muted-foreground/60',
              'hover:text-muted-foreground/80 transition-colors',
              'focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1',
              'rounded px-1'
            )}
            aria-label={`Version ${buildInfo.version}, commit ${shortCommit}`}
          >
            v{buildInfo.version} | {shortCommit}
          </button>
        </TooltipTrigger>
        <TooltipContent
          side="bottom"
          align="end"
          className="w-72 p-0"
        >
          <div className="p-3 space-y-3">
            {/* Build Information */}
            <div>
              <h4 className="text-xs font-semibold mb-2">Build Information</h4>
              <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
                <dt className="text-muted-foreground">Version</dt>
                <dd className="font-mono">{buildInfo.version}</dd>
                <dt className="text-muted-foreground">Commit</dt>
                <dd className="font-mono truncate" title={buildInfo.commit}>
                  {buildInfo.commit}
                </dd>
                <dt className="text-muted-foreground">Built</dt>
                <dd>{formattedDate}</dd>
                {buildInfo.branch && (
                  <>
                    <dt className="text-muted-foreground">Branch</dt>
                    <dd className="font-mono">{buildInfo.branch}</dd>
                  </>
                )}
              </dl>
            </div>

            {/* Recent Changes */}
            {changelog.length > 0 && (
              <div className="border-t pt-3">
                <h4 className="text-xs font-semibold mb-2">Recent Changes</h4>
                <div className="space-y-2">
                  {changelog.map((version) => (
                    <div key={version.version}>
                      <div className="text-xs font-medium text-muted-foreground mb-1">
                        v{version.version}
                      </div>
                      <ul className="space-y-0.5 text-xs">
                        {[...version.features, ...version.fixes, ...version.other].map(
                          (change, idx) => (
                            <li key={idx} className="flex gap-1.5">
                              <span>{getIcon(change.icon)}</span>
                              <span className="line-clamp-1">{change.description}</span>
                            </li>
                          )
                        )}
                      </ul>
                    </div>
                  ))}
                </div>
              </div>
            )}
          </div>
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}
```

### Next.js Config: `next.config.mjs`

```javascript
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';

function getBuildInfo() {
  const version = process.env.npm_package_version || 'dev';
  const commit = process.env.VERCEL_GIT_COMMIT_SHA
    || process.env.GITHUB_SHA
    || execSyncSafe('git rev-parse HEAD')
    || 'local';
  const branch = process.env.VERCEL_GIT_COMMIT_REF
    || process.env.GITHUB_REF_NAME
    || execSyncSafe('git branch --show-current')
    || 'local';

  return {
    version,
    commit,
    branch,
    buildTime: new Date().toISOString(),
  };
}

function execSyncSafe(cmd) {
  try {
    return execSync(cmd, { encoding: 'utf-8' }).trim();
  } catch {
    return null;
  }
}

function getChangelog() {
  try {
    return execSync('node scripts/parse-changelog.mjs', { encoding: 'utf-8' }).trim();
  } catch {
    return '[]';
  }
}

/** @type {import('next').NextConfig} */
const nextConfig = {
  env: {
    NEXT_PUBLIC_BUILD_INFO: JSON.stringify(getBuildInfo()),
    NEXT_PUBLIC_CHANGELOG: getChangelog(),
  },
};

export default nextConfig;
```

## Vue 3 + Tailwind Implementation

### Component: `components/VersionBadge.vue`

```vue
<script setup lang="ts">
import { computed, ref } from 'vue';

interface BuildInfo {
  version: string;
  commit: string;
  branch: string;
  buildTime: string;
}

interface ChangeEntry {
  type: string;
  icon: string;
  description: string;
}

interface VersionEntry {
  version: string;
  features: ChangeEntry[];
  fixes: ChangeEntry[];
  other: ChangeEntry[];
}

const ICON_MAP: Record<string, string> = {
  sparkles: '✨',
  bug: '🐛',
  zap: '⚡',
  warning: '⚠️',
  recycle: '♻️',
  book: '📖',
};

const isOpen = ref(false);

const buildInfo = computed<BuildInfo | null>(() => {
  try {
    const raw = import.meta.env.VITE_BUILD_INFO;
    return raw ? JSON.parse(raw) : null;
  } catch {
    return null;
  }
});

const changelog = computed<VersionEntry[]>(() => {
  try {
    const raw = import.meta.env.VITE_CHANGELOG;
    return raw ? JSON.parse(raw) : [];
  } catch {
    return [];
  }
});

const shortCommit = computed(() => buildInfo.value?.commit?.slice(0, 7) || 'unknown');

const formattedDate = computed(() => {
  if (!buildInfo.value?.buildTime) return 'Unknown';
  return new Date(buildInfo.value.buildTime).toLocaleString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
    timeZoneName: 'short',
  });
});

const allChanges = (version: VersionEntry) => [
  ...version.features,
  ...version.fixes,
  ...version.other,
];

const getIcon = (iconName: string) => ICON_MAP[iconName] || '•';
</script>

<template>
  <div v-if="buildInfo?.version && buildInfo.version !== 'dev'" class="relative">
    <button
      class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground/80
             transition-colors focus:outline-none focus:ring-1 focus:ring-ring
             focus:ring-offset-1 rounded px-1"
      :aria-label="`Version ${buildInfo.version}, commit ${shortCommit}`"
      @mouseenter="isOpen = true"
      @mouseleave="isOpen = false"
      @focus="isOpen = true"
      @blur="isOpen = false"
    >
      v{{ buildInfo.version }} | {{ shortCommit }}
    </button>

    <Teleport to="body">
      <Transition name="fade">
        <div
          v-if="isOpen"
          class="fixed z-50 w-72 bg-popover text-popover-foreground rounded-md
                 border shadow-md p-3 space-y-3"
          :style="tooltipPosition"
        >
          <!-- Build Information -->
          <div>
            <h4 class="text-xs font-semibold mb-2">Build Information</h4>
            <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
              <dt class="text-muted-foreground">Version</dt>
              <dd class="font-mono">{{ buildInfo.version }}</dd>
              <dt class="text-muted-foreground">Commit</dt>
              <dd class="font-mono truncate" :title="buildInfo.commit">
                {{ buildInfo.commit }}
              </dd>
              <dt class="text-muted-foreground">Built</dt>
              <dd>{{ formattedDate }}</dd>
              <template v-if="buildInfo.branch">
                <dt class="text-muted-foreground">Branch</dt>
                <dd class="font-mono">{{ buildInfo.branch }}</dd>
              </template>
            </dl>
          </div>

          <!-- Recent Changes -->
          <div v-if="changelog.length > 0" class="border-t pt-3">
            <h4 class="text-xs font-semibold mb-2">Recent Changes</h4>
            <div class="space-y-2">
              <div v-for="version in changelog" :key="version.version">
                <div class="text-xs font-medium text-muted-foreground mb-1">
                  v{{ version.version }}
                </div>
                <ul class="space-y-0.5 text-xs">
                  <li
                    v-for="(change, idx) in allChanges(version)"
                    :key="idx"
                    class="flex gap-1.5"
                  >
                    <span>{{ getIcon(change.icon) }}</span>
                    <span class="line-clamp-1">{{ change.description }}</span>
                  </li>
                </ul>
              </div>
            </div>
          </div>
        </div>
      </Transition>
    </Teleport>
  </div>
</template>

<style scoped>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>
```

### Vite Config: `vite.config.ts`

```typescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { execSync } from 'child_process';

function execSyncSafe(cmd: string): string | null {
  try {
    return execSync(cmd, { encoding: 'utf-8' }).trim();
  } catch {
    return null;
  }
}

function getBuildInfo() {
  return {
    version: process.env.npm_package_version || 'dev',
    commit: process.env.GITHUB_SHA || execSyncSafe('git rev-parse HEAD') || 'local',
    branch: process.env.GITHUB_REF_NAME || execSyncSafe('git branch --show-current') || 'local',
    buildTime: new Date().toISOString(),
  };
}

function getChangelog(): string {
  try {
    return execSync('node scripts/parse-changelog.mjs', { encoding: 'utf-8' }).trim();
  } catch {
    return '[]';
  }
}

export default defineConfig({
  plugins: [vue()],
  define: {
    'import.meta.env.VITE_BUILD_INFO': JSON.stringify(JSON.stringify(getBuildInfo())),
    'import.meta.env.VITE_CHANGELOG': JSON.stringify(getChangelog()),
  },
});
```

## Svelte Implementation

### Component: `lib/components/VersionBadge.svelte`

```svelte
<script lang="ts">
  import { onMount } from 'svelte';

  interface BuildInfo {
    version: string;
    commit: string;
    branch: string;
    buildTime: string;
  }

  interface ChangeEntry {
    type: string;
    icon: string;
    description: string;
  }

  interface VersionEntry {
    version: string;
    features: ChangeEntry[];
    fixes: ChangeEntry[];
    other: ChangeEntry[];
  }

  const ICON_MAP: Record<string, string> = {
    sparkles: '✨',
    bug: '🐛',
    zap: '⚡',
    warning: '⚠️',
    recycle: '♻️',
    book: '📖',
  };

  let isOpen = false;
  let triggerEl: HTMLButtonElement;

  const buildInfo: BuildInfo | null = (() => {
    try {
      return import.meta.env.VITE_BUILD_INFO
        ? JSON.parse(import.meta.env.VITE_BUILD_INFO)
        : null;
    } catch {
      return null;
    }
  })();

  const changelog: VersionEntry[] = (() => {
    try {
      return import.meta.env.VITE_CHANGELOG
        ? JSON.parse(import.meta.env.VITE_CHANGELOG)
        : [];
    } catch {
      return [];
    }
  })();

  $: shortCommit = buildInfo?.commit?.slice(0, 7) || 'unknown';

  $: formattedDate = buildInfo?.buildTime
    ? new Date(buildInfo.buildTime).toLocaleString('en-US', {
        month: 'short',
        day: 'numeric',
        year: 'numeric',
        hour: 'numeric',
        minute: '2-digit',
        timeZoneName: 'short',
      })
    : 'Unknown';

  const getIcon = (iconName: string) => ICON_MAP[iconName] || '•';

  const allChanges = (version: VersionEntry) => [
    ...version.features,
    ...version.fixes,
    ...version.other,
  ];
</script>

{#if buildInfo?.version && buildInfo.version !== 'dev'}
  <div class="relative">
    <button
      bind:this={triggerEl}
      class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground/80
             transition-colors focus:outline-none focus:ring-1 focus:ring-ring
             focus:ring-offset-1 rounded px-1"
      aria-label={`Version ${buildInfo.version}, commit ${shortCommit}`}
      on:mouseenter={() => (isOpen = true)}
      on:mouseleave={() => (isOpen = false)}
      on:focus={() => (isOpen = true)}
      on:blur={() => (isOpen = false)}
    >
      v{buildInfo.version} | {shortCommit}
    </button>

    {#if isOpen}
      <div
        class="absolute right-0 top-full mt-1 z-50 w-72 bg-popover
               text-popover-foreground rounded-md border shadow-md p-3 space-y-3"
        role="tooltip"
      >
        <!-- Build Information -->
        <div>
          <h4 class="text-xs font-semibold mb-2">Build Information</h4>
          <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
            <dt class="text-muted-foreground">Version</dt>
            <dd class="font-mono">{buildInfo.version}</dd>
            <dt class="text-muted-foreground">Commit</dt>
            <dd class="font-mono truncate" title={buildInfo.commit}>
              {buildInfo.commit}
            </dd>
            <dt class="text-muted-foreground">Built</dt>
            <dd>{formattedDate}</dd>
            {#if buildInfo.branch}
              <dt class="text-muted-foreground">Branch</dt>
              <dd class="font-mono">{buildInfo.branch}</dd>
            {/if}
          </dl>
        </div>

        <!-- Recent Changes -->
        {#if changelog.length > 0}
          <div class="border-t pt-3">
            <h4 class="text-xs font-semibold mb-2">Recent Changes</h4>
            <div class="space-y-2">
              {#each changelog as version}
                <div>
                  <div class="text-xs font-medium text-muted-foreground mb-1">
                    v{version.version}
                  </div>
                  <ul class="space-y-0.5 text-xs">
                    {#each allChanges(version) as change, idx}
                      <li class="flex gap-1.5">
                        <span>{getIcon(change.icon)}</span>
                        <span class="line-clamp-1">{change.description}</span>
                      </li>
                    {/each}
                  </ul>
                </div>
              {/each}
            </div>
          </div>
        {/if}
      </div>
    {/if}
  </div>
{/if}
```

## Plain CSS Implementation

For projects without Tailwind, use CSS custom properties:

```css
/* version-badge.css */
.version-badge {
  --vb-font-size: 10px;
  --vb-color: rgba(var(--foreground-rgb), 0.6);
  --vb-color-hover: rgba(var(--foreground-rgb), 0.8);
  --vb-tooltip-bg: var(--background);
  --vb-tooltip-border: var(--border);
  --vb-tooltip-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

.version-badge__trigger {
  font-size: var(--vb-font-size);
  color: var(--vb-color);
  background: transparent;
  border: none;
  padding: 2px 4px;
  border-radius: 4px;
  cursor: pointer;
  transition: color 0.15s ease;
}

.version-badge__trigger:hover,
.version-badge__trigger:focus {
  color: var(--vb-color-hover);
}

.version-badge__trigger:focus {
  outline: 1px solid var(--ring);
  outline-offset: 1px;
}

.version-badge__tooltip {
  position: absolute;
  right: 0;
  top: 100%;
  margin-top: 4px;
  width: 288px;
  background: var(--vb-tooltip-bg);
  border: 1px solid var(--vb-tooltip-border);
  border-radius: 6px;
  box-shadow: var(--vb-tooltip-shadow);
  padding: 12px;
  z-index: 50;
}

.version-badge__section-title {
  font-size: 12px;
  font-weight: 600;
  margin-bottom: 8px;
}

.version-badge__info-grid {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 12px;
  font-size: 12px;
}

.version-badge__info-label {
  color: var(--vb-color);
}

.version-badge__info-value {
  font-family: monospace;
}

.version-badge__changes {
  border-top: 1px solid var(--vb-tooltip-border);
  padding-top: 12px;
  margin-top: 12px;
}

.version-badge__change-item {
  display: flex;
  gap: 6px;
  font-size: 12px;
  margin-bottom: 2px;
}
```

## Accessibility Checklist

- [x] Keyboard accessible (focusable button)
- [x] `aria-label` with version and commit info
- [x] Focus ring visible
- [x] Tooltip triggered by both hover and focus
- [x] Proper color contrast (WCAG AA)
- [x] Screen reader announces version info

## Agentic Optimizations

| Context | Action |
|---------|--------|
| Quick implementation | Use `/components:version-badge` command |
| Check compatibility | `/components:version-badge --check-only` |
| Custom placement | `/components:version-badge --location footer` |

## Quick Reference

| Framework | Env Prefix | Config File |
|-----------|------------|-------------|
| Next.js | `NEXT_PUBLIC_` | `next.config.mjs` |
| Nuxt | `NUXT_PUBLIC_` | `nuxt.config.ts` |
| Vite | `VITE_` | `vite.config.ts` |
| SvelteKit | `PUBLIC_` | `svelte.config.js` |
| CRA | `REACT_APP_` | N/A (eject or craco) |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…