Skip to content

Design Principles

The design principles behind the bejamas/ui library.

bejamas/ui is an Astro-native component library. The way components are structured is as important as how they look.

  1. Flat-file structure. One file = one component. Markup, variants, and component parts (e.g. header, content, footer) live together.
  2. Docs in code. Documentation is auto-generated from comments within each component file; there’s no separate source of truth to maintain.
  3. Themeable. Components use token-based classes (e.g. bg-background, text-primary) instead of hard-coded colors.
  4. Composable. We prefer small primitives over heavy abstractions. Code should be easy to read, copy, and modify.
  5. Astro-native, zero-JS by default. Components are server-first and ship no client JavaScript unless they truly need it.
  6. Framework-agnostic. We don’t bundle React, Vue, or any other client framework. You’re free to add your own Astro islands or framework of choice on top where needed.
  7. shadcn/ui aligned. shadcn/ui aligned. We follow shadcn’s markup patterns and class naming where useful, so you can reuse component examples and patterns from the shadcn registry and adapt them easily for Astro.

React libraries often use subcomponents, for example:

import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from "@bejamas/ui/components/card";
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
</Card>;

Astro doesn’t support <Card.Header> syntax, and we want to keep one file per component, not one file per subcomponent.

Instead, we use a part prop on the same component.

---
import Card from "@bejamas/ui/components/card";
---
<Card>
<Card part="header">
<Card part="title">Title</Card>
<Card part="description">Description</Card>
</Card>
<Card part="content"> Card content </Card>
<Card part="footer"> Footer actions </Card>
</Card>

Here:

  • Card without part is the root card.
  • Card part="header" renders the header part and is slotted into the root.
  • Card part="title" and Card part="description" are smaller primitives rendered inside the header.
  • Card part="content" and Card part="footer" do the same for content and footer.

All of this is implemented in a single Card.astro file.

The same component file switches on part and renders the right fragment:

---
type CardPart =
| "root"
| "header"
| "title"
| "description"
| "content"
| "footer";
export type Props = {
part?: CardPart;
class?: string;
// …
};
const { part = "root", class: className = "", ...rest } = Astro.props as Props;
---
{
part === "root" && (
<div class="card-root" {...rest}>
<slot />
</div>
)
}
{
part === "header" && (
<div slot="header" class="card-header" {...rest}>
<slot />
</div>
)
}
{
part === "title" && (
<div data-slot="card-title" class="card-title" {...rest}>
<slot />
</div>
)
}
{
part === "description" && (
<div data-slot="card-description" class="card-description" {...rest}>
<slot />
</div>
)
}
{
part === "content" && (
<div slot="content" class="card-content" {...rest}>
<slot />
</div>
)
}
{
part === "footer" && (
<div slot="footer" class="card-footer" {...rest}>
<slot />
</div>
)
}

This gives us:

  • a single, copy-and-pasteable file,
  • multiple “subcomponents” via part,
  • and predictable DOM/slots for styling and theming.

We experimented with a slots-only API and ran into several limitations.

  1. Slots can’t be nested. You can’t express a header that contains its own named slots for title and description:
<!-- This won't work -->
<div>
<slot name="header">
<slot name="title" />
<slot name="description" />
</slot>
</div>
  1. Slots can’t receive props. You can’t pass layout or variant information into a slot:
<!-- This won't work -->
<div>
<slot name="header" class="text-2xl font-bold" />
</div>
  1. Styling depends on user wrappers. With slots, the final DOM changes depending on whether the user wraps content:
---
import Card from "@bejamas/ui/components/card";
import Button from "@bejamas/ui/components/button";
---
<Card>
<Fragment slot="footer">
<Button>Save</Button>
</Fragment>
<div slot="footer">
<Button>Save</Button>
</div>
</Card>

In the first case the button is a direct child of the footer; in the second it’s wrapped in an extra <div>.

This changes how flex, gap, and other layout rules behave and makes consistent styling harder.

  1. Slot order is fixed inside the component. The order of named slots is decided in the implementation:
<div class="card-root">
<slot name="header" />
<slot name="media" />
<slot name="content" />
<slot name="footer" />
</div>

Users can’t reorder these sections (for example, putting the media above the content) without forking the component.

The part prop solves these problems:

  • layout is defined in one place (the component),
  • parts can be added and composed inside that layout,
  • and the public API stays small and predictable.

We want components to be easy to copy into projects and easy to read in one go.

One file per component means:

  • open Card.astro and you see root, header, title, description, content, footer, overlay, and variants in one place;
  • docs comments live next to the implementation;
  • copying a component doesn’t require chasing a tree of imports.

React shadcn uses multiple exports per file (Card, CardHeader, CardFooter, …).

In Astro we get a similar multi-part API by using a single component with a part prop instead of multiple files.

Built at Bejamas