Astra UI
A published React component library with type-safe variants, polymorphic primitives, accessibility tested, automated CI/CD
Astra UI is a React component library I built to solve a specific problem: every team I've worked on rebuilds the same dozen primitives (buttons, inputs, dialogs, tooltips), but the variants always end up scattered across className chains, prop drilling, and inconsistent APIs. The library packages those primitives with a type-safe variant system, polymorphic component patterns, and accessibility tested in CI from day one.
It ships as @astra-ui-lib/core on npm with tree-shakeable ESM and CJS outputs. The repo is a Turborepo monorepo with the core library, a documentation site, and downstream consumer fixtures sharing the same TypeScript config. Every PR runs unit tests, type checks, and jest-axe accessibility assertions before merge. Releases are fully automated through Changesets. Open a PR with a changeset, merge it, and a GitHub Action publishes a new version with a generated changelog.
The bigger goal: turn a familiar engineering pattern (component libraries) into a reusable artifact, and use it as a place to push on the parts of frontend that are usually left undone: accessibility, type safety, semver discipline.
as prop with full TypeScript inference. Render a Button as an <a> and the link-only props become available; render it as a <button> and they don't.exports map for modern and legacy bundlers.Variants are the most common pain point in component libraries. CVA lets you declare them as data instead of conditional class logic, and the types fall out of the declaration:
const buttonStyles = cva(
"inline-flex items-center justify-center font-medium",
{
variants: {
intent: {
primary: "bg-accent text-white",
secondary: "border border-fg/10 text-fg",
ghost: "hover:bg-fg/5 text-fg",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-base",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: { intent: "primary", size: "md" },
}
);
// Usage: fully typed, IntelliSense-friendly:
<Button intent="ghost" size="sm">Cancel</Button>
The type of intent is the union of its keys, the type of size is the union of its keys, and a consumer who passes intent="invalid" gets a compile error. No runtime validation, no PropTypes, no string fall-through.
A common library bug: a Button renders as an <a> when you pass href, but TypeScript still thinks it's a button and offers type as a prop. The polymorphic pattern fixes this by making as a generic that drives the prop intersection:
<Button as="a" href="/login">Sign in</Button> // `href` is required and typed as string // `type` is NOT in autocomplete <Button as="button" type="submit">Submit</Button> // `type` is required and typed as button-type union // `href` is NOT in autocomplete
Each primitive uses the same PolymorphicComponentProps helper, so the API is consistent across the library and the type narrowing works the same way everywhere.
The repo is a Turborepo monorepo. The core package is what ships to npm; the docs package is a separate Next.js site that imports the published library to pin documentation to a real released version. CI uses Turborepo's remote cache so unchanged packages skip work entirely on every PR.
Releases are driven by Changesets. A contributor adds a markdown changeset describing what changed and at what semver level (patch / minor / major). A GitHub Action watches the main branch, and when it sees pending changesets, it opens or updates a "Release PR" with the computed version bumps and changelog. Merging that PR runs another action that runs the Rollup build, publishes to npm with provenance, tags the release, and creates a GitHub Release with the generated notes.
The end state: a contributor only writes a changeset, never touches version numbers or changelogs, and releases happen on their own.