Your branding package

Keep your logo and brand components in their own package, on top of the generic ones.

@voila.dev/ui deliberately contains no logo, no wordmark and no brand components. That is not an oversight — it is the line that makes the rest of it reusable.

Your brand belongs in a package you own. This page describes the one that sits next to it, and it is the same shape @voila.dev uses internally for its own ui-branding.

Why a separate package

A brand asset has a different lifecycle from a component. Button changes when someone improves the focus ring. Your logo changes when marketing signs off on a new one — and when it does, it has to change in every app on the same day.

Keeping them apart buys you three things:

  1. One source of truth for the mark. No app ships a stale SVG, because no app has its own copy.
  2. A clean upgrade path. bun update @voila.dev/ui never touches your brand.
  3. Something you can keep private. The generic layer comes from public npm; the brand layer never leaves your registry.

The shape

packages/branding/
├── package.json
├── src/
│   ├── assets/
│   │   ├── logo.svg          # the full lockup
│   │   ├── brand-mark.svg    # the mark alone, for favicons and avatars
│   │   └── og-default.png    # the social card fallback
│   ├── assets.d.ts
│   └── components/
│       ├── logo.tsx
│       └── brand-footer.tsx
└── tsconfig.json
packages/branding/package.json
{
	"name": "@your-product/branding",
	"private": true,
	"type": "module",
	"exports": {
		"./assets/*": "./src/assets/*",
		"./components/*": "./src/components/*.tsx"
	},
	"imports": { "#/*": "./src/*" },
	"dependencies": { "@voila.dev/ui": "catalog:voila" },
	"peerDependencies": { "react": "catalog:react" }
}

"private": true is deliberate. This package is never published; workspace links are the only way anything consumes it.

The Logo component

The single most-copied asset in any product. Make it a component so the sizes and the accessible name are decided once:

packages/branding/src/components/logo.tsx
import { cn } from "@voila.dev/ui/utils";
import logoUrl from "#/assets/logo.svg";
 
const sizeClasses = {
	sm: "h-6",
	md: "h-8",
	lg: "h-10",
} as const;
 
export function Logo({
	className,
	size = "md",
	tone = "default",
	alt = "",
}: {
	readonly className?: string;
	readonly size?: keyof typeof sizeClasses;
	/** On a dark surface, render the wordmark in white. */
	readonly tone?: "default" | "inverted";
	/** Keep the default "" when a parent link already names the brand. */
	readonly alt?: string;
}) {
	return (
		<img
			src={logoUrl}
			alt={alt}
			className={cn(
				"w-auto shrink-0",
				sizeClasses[size],
				tone === "inverted" && "brightness-0 invert",
				className,
			)}
		/>
	);
}

Two details worth stealing:

  • alt defaults to "". A logo inside a link that already says "Home" should not announce itself twice. Callers who need the name pass it.
  • The component never renders a link. Where it navigates is the caller's business; a logo in a footer and a logo in a header go to the same place for different reasons.

You will need the SVG import declared:

packages/branding/src/assets.d.ts
declare module "*.svg" {
	const url: string;
	export default url;
}

What belongs here, and what does not

Belongs in brandingBelongs in your app
Logo, wordmark, brand markPage layouts
Brand-specific illustrationsFeature components
The default social cardRoute-specific copy
A branded footer used by every appAn app-specific footer

The test: would a second product in your company use it unchanged? If yes, it is branding. If it only makes sense inside one app, it is app code.

Building on the generic components

Brand components compose @voila.dev/ui, they do not reimplement it:

packages/branding/src/components/brand-footer.tsx
import { Separator } from "@voila.dev/ui/separator";
import { Logo } from "#/components/logo.tsx";
 
export function BrandFooter({ children }: { readonly children?: React.ReactNode }) {
	return (
		<footer className="flex flex-col gap-6 py-10">
			<Logo size="sm" />
			<Separator />
			<div className="text-muted-foreground text-sm">{children}</div>
		</footer>
	);
}

If you catch yourself rebuilding a Button inside the branding package because "ours is different", the difference is almost always a token. Go set it in your theme package instead — the whole system moves with it.