diff --git a/components/ui/PrimaryButton.test.tsx b/components/ui/PrimaryButton.test.tsx
new file mode 100644
index 00000000..dc0ff464
--- /dev/null
+++ b/components/ui/PrimaryButton.test.tsx
@@ -0,0 +1,175 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import PrimaryButton from './PrimaryButton'
+
+describe('PrimaryButton', () => {
+ // ──────────────────────────────────────────────────────────────────────────
+ // Rendering
+ // ──────────────────────────────────────────────────────────────────────────
+
+ it('renders with children', () => {
+ render(Submit)
+ expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
+ })
+
+ it('renders with complex children', () => {
+ render(
+
+
+ Pay Now
+ ,
+ )
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByTestId('icon')).toBeInTheDocument()
+ expect(screen.getByText('Pay Now')).toBeInTheDocument()
+ })
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Default styling
+ // ──────────────────────────────────────────────────────────────────────────
+
+ it('applies base visual classes', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ // Base layout classes
+ expect(button.className).toContain('inline-flex')
+ expect(button.className).toContain('items-center')
+ expect(button.className).toContain('justify-center')
+ // Brand colour
+ expect(button.className).toContain('bg-brand.red')
+ expect(button.className).toContain('text-white')
+ // Sizing
+ expect(button.className).toContain('px-6')
+ expect(button.className).toContain('py-3')
+ expect(button.className).toContain('rounded-lg')
+ expect(button.className).toContain('font-semibold')
+ // Transition
+ expect(button.className).toContain('transition-colors')
+ expect(button.className).toContain('duration-150')
+ })
+
+ it('applies hover classes', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ expect(button.className).toContain('hover:bg-brand.redHover')
+ })
+
+ it('applies focus classes', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ expect(button.className).toContain('focus:outline-none')
+ expect(button.className).toContain('focus:ring-2')
+ expect(button.className).toContain('focus:ring-brand.red')
+ expect(button.className).toContain('focus:ring-offset-2')
+ })
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Disabled state
+ // ──────────────────────────────────────────────────────────────────────────
+
+ it('renders disabled when disabled prop is true', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ expect(button).toBeDisabled()
+ })
+
+ it('sets aria-disabled when disabled', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ expect(button).toHaveAttribute('aria-disabled', 'true')
+ })
+
+ it('does not set aria-disabled when enabled', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ expect(button).not.toHaveAttribute('aria-disabled')
+ })
+
+ it('applies disabled visual classes', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ expect(button.className).toContain('disabled:opacity-50')
+ expect(button.className).toContain('disabled:cursor-not-allowed')
+ expect(button.className).toContain('disabled:hover:bg-brand.red')
+ })
+
+ it('does not trigger onClick when disabled', async () => {
+ const user = userEvent.setup()
+ let clicked = false
+ render(
+ { clicked = true }}>
+ Send
+ ,
+ )
+ await user.click(screen.getByRole('button'))
+ expect(clicked).toBe(false)
+ })
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Custom className merging
+ // ──────────────────────────────────────────────────────────────────────────
+
+ it('merges custom className with default classes', () => {
+ render(Send)
+ const button = screen.getByRole('button')
+ expect(button.className).toContain('extra-class')
+ // Default classes should still be present
+ expect(button.className).toContain('bg-brand.red')
+ expect(button.className).toContain('inline-flex')
+ })
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // forwardRef support
+ // ──────────────────────────────────────────────────────────────────────────
+
+ it('forwards ref to the button element', () => {
+ const ref = { current: null as HTMLButtonElement | null }
+ render(Send)
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement)
+ expect(ref.current?.tagName).toBe('BUTTON')
+ })
+
+ it('has correct displayName', () => {
+ expect(PrimaryButton.displayName).toBe('PrimaryButton')
+ })
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Additional HTML button attributes
+ // ──────────────────────────────────────────────────────────────────────────
+
+ it('supports type attribute', () => {
+ render(Submit)
+ expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
+ })
+
+ it('supports aria-label', () => {
+ render(X)
+ expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument()
+ })
+
+ it('supports data-* attributes', () => {
+ render(Send)
+ expect(screen.getByTestId('submit-btn')).toBeInTheDocument()
+ })
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Interaction parity: hover then focus
+ // ──────────────────────────────────────────────────────────────────────────
+
+ it('maintains hover class when focused', async () => {
+ const user = userEvent.setup()
+ render(Send)
+ const button = screen.getByRole('button')
+
+ await user.hover(button)
+ // Hover classes are present
+ expect(button.className).toContain('hover:bg-brand.redHover')
+
+ // Focus does not remove hover
+ button.focus()
+ expect(button.className).toContain('hover:bg-brand.redHover')
+ // Focus classes are also present
+ expect(button.className).toContain('focus:ring-2')
+ })
+})
\ No newline at end of file
diff --git a/lib/utils.ts b/lib/utils.ts
index 365058ce..607cb86e 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,6 +1 @@
-import { type ClassValue, clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
+export { cn } from "./utils/cn";
diff --git a/lib/utils/index.ts b/lib/utils/index.ts
index 37295e1c..c25627d5 100644
--- a/lib/utils/index.ts
+++ b/lib/utils/index.ts
@@ -1,6 +1 @@
-import { clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
-
-export function cn(...inputs: Parameters) {
- return twMerge(clsx(inputs));
-}
+export { cn } from "./cn";