@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:
- One source of truth for the mark. No app ships a stale SVG, because no app has its own copy.
- A clean upgrade path.
bun update @voila.dev/uinever touches your brand. - 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{
"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:
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:
altdefaults 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:
declare module "*.svg" {
const url: string;
export default url;
}What belongs here, and what does not
Belongs in branding | Belongs in your app |
|---|---|
| Logo, wordmark, brand mark | Page layouts |
| Brand-specific illustrations | Feature components |
| The default social card | Route-specific copy |
| A branded footer used by every app | An 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:
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.