Contributing
Coding guidelines for anyone adding to or modifying vajra-ui: naming conventions, folder
structure, and the component-authoring pattern every component in the library follows.
Naming Conventions
Types
All TypeScript type declarations must be prefixed with T. No interface declarations anywhere in the project — use type only.
// correct
type TButtonVariant = 'solid' | 'outline' | 'ghost';
type TBoxProps = { ... };
// wrong
type ButtonVariant = 'solid' | 'outline' | 'ghost';
interface BoxProps { ... } // interfaces are banned
Exports
Named exports only. No default exports.
// correct
export const Box = memo(BoxComponent);
export type { TBoxProps };
// wrong
export default Box;
This applies to components, hooks, utilities, constants, and types.
Folder Structure
Every component lives in its own folder. The folder name uses kebab-case and matches the component name.
foobar/
├── foobar.tsx # UI of the component
├── foobar-types.ts # All types for this component
├── foobar-constants.ts # Constants scoped to this component
├── foobar-variants.ts # Variant definitions (components only, not core primitives)
├── use-foobar.ts # Custom hook if the component has non-trivial logic
├── components/ # Sub-components (only if the component is compositional)
│ ├── foobar-header/
│ │ ├── foobar-header.tsx
│ │ ├── foobar-header-types.ts
│ │ └── index.ts
│ └── foobar-footer/
│ ├── foobar-footer.tsx
│ ├── foobar-footer-types.ts
│ └── index.ts
└── index.ts # Barrel — re-exports everything consumers need
When sub-components exist, they are attached as static properties inside foobar.tsx itself. index.ts stays a plain barrel.
// foobar/foobar.tsx
import { FoobarHeader } from './components/foobar-header';
import { FoobarFooter } from './components/foobar-footer';
export const Foobar = {
Header: FoobarHeader,
Footer: FoobarFooter,
};
// foobar/index.ts — plain barrel, no composition logic
export * from './foobar';
export * from './foobar-types';
// usage
<Foobar.Header />
<Foobar.Footer />
Rules per file
| File | Purpose |
|---|---|
foobar.tsx | JSX only. No business logic. Uses props from foobar-types.ts. |
foobar-types.ts | All type declarations. No runtime code. |
foobar-constants.ts | Constant values used by the component (e.g. default sizes, maps). |
foobar-variants.ts | Variant style maps or config. Present for components, not core primitives. |
use-foobar.ts | Custom hook. Only add when the component has logic that cannot live in the component itself. |
index.ts | Re-exports with export * from. Do not add logic here. |
index.ts pattern
// foobar/index.ts
export * from './foobar';
export * from './foobar-types';
// only export variants/constants/hook if consumers need them
export * from './foobar-variants';
File Naming
All file names use kebab-case.
button.tsx correct
button-variants.ts correct
use-button.ts correct
Button.tsx wrong
buttonVariants.ts wrong
useButton.ts wrong
Component Pattern
// foobar/foobar.tsx
import React, { memo } from 'react';
import { TFoobarProps } from './foobar-types';
export const Foobar = memo<TFoobarProps>(({ ... }) => { return ( ... ); });
- Component and memo export are written as a single inline declaration
- Props type lives in
foobar-types.ts
Token Usage in Components
Never use raw values for colors, spacing, radii, or font scales. Always reference tokens through the theme.
// correct
<Box backgroundColor="primary" p="md" br="lg" />
// wrong
<View style={{ backgroundColor: '#0066FF', padding: 16, borderRadius: 8 }} />
Token prop types are key-based:
TColorToken—'primary' | 'surface' | 'error' | 'text' | ...TSpacingToken—'s-1' | 's-2' | 's-3' | 's-4' | 's-6' | 's-8' | ...TRoundedToken—'r-1' | 'r-2' | 'r-3' | 'r-4' | 'r-full' | ...TFontVariant—'heading' | 'subheading' | 'body' | 'bodySmall' | 'label' | 'caption' | 'button'
Adding a New Component — Step by Step
Use this recipe every time. It's the same shape button, badge, card, and every other component in src/ui/components/ already follow — don't improvise a new structure per component.
Simple component (no sub-parts — e.g. Badge, Spinner, Avatar)
- Create
src/ui/components/<name>/(kebab-case). <name>-types.ts— the props type, namedT<Name>Props. No runtime code.<name>-constants.ts— only if the component has fixed values (default sizes, icon maps, etc.). Skip if not needed.<name>-variants.ts— only if the component has visual variants (solid/outline/ghost, size scales, etc.), viacreateRecipe(seesrc/ui/utils/create-recipe.tsandbutton-variants.tsfor the pattern). Skip for components with no variants.<name>.tsx— the component itself. Import only fromsrc/ui/core(never@devraj-labs/vajra-ui-coredirectly — ESLint enforces this) anduseVajraThemefor token resolution. Follow the inlinememopattern:export const Foobar = memo<TFoobarProps>(({ ... }) => { return (...); });Foobar.displayName = 'Foobar';index.ts— plain barrel:export * from './<name>'+export * from './<name>-types'(+ variants/constants only if consumers need them directly).- Export the barrel from
src/ui/components/index.ts(or wherever the components barrel aggregates) so it reaches the publicsrc/index.tssurface. - Write
<name>.test.tsxcolocated next to<name>.tsx(see Testing section below). - Add a preview screen under
examples/app/src/screens/examples/following the existing per-component screen pattern, and register it incomponent-registry.tsxso it shows up in the component gallery.
Compositional component (has sub-parts — e.g. Checkbox, Radio, Switch, Input)
Same as above, plus:
<name>-context.ts(or.tsxif it renders a provider) — a React context carrying shared state (selected value(s), change handler, disabled state, shared color) down to sub-components. Seecheckbox-context.ts.<name>-root.tsx— the top-level component that owns state/context and renders children inside the context provider (e.g.CheckboxRoot,RadioRoot,SwitchRoot). This is what consumers actually mount.components/— one folder per sub-component (e.g.checkbox-item/,checkbox-indicator/,checkbox-label/), each with its own<sub>.tsx+<sub>-types.ts+index.ts, consuming the parent context via ause<Name>Context()hook rather than receiving everything as props.- Sub-components are attached as static properties on the root export in the main
.tsxfile:export const Checkbox = {Root: CheckboxRoot,Item: CheckboxItem,Indicator: CheckboxIndicator,Label: CheckboxLabel,}; - Write one test file per meaningfully-testable piece (root + composed usage is usually enough; you don't need to unit-test every sub-component in isolation if the composed test already exercises it).
Testing a new component
Every component's theme/token props are resolved via useVajraTheme(), so it must be rendered inside <VajraProvider> in tests or it throws. Minimal shape:
import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { VajraProvider } from '../../vajra-theme';
import { Foobar } from './foobar';
describe('Foobar', () => {
it('renders', () => {
render(
<VajraProvider>
<Foobar label="Hello" />
</VajraProvider>,
);
expect(screen.getByText('Hello')).toBeTruthy();
});
});
At minimum, cover: renders with required props, key prop/variant variations produce the expected output, and — for token-driven props (bg, color, p, rounded, etc.) — that the resolved value actually comes from the theme (this is the library's core value proposition, so it's the most important thing to verify).
Layer Rules
| Layer | Location | Description |
|---|---|---|
| Core | @devraj-labs/vajra-ui-core | Headless, unstyled. No theme context. No useVajraTheme. Raw primitives only. |
| UI Core | src/ui/core/ | Token-aware wrappers around @devraj-labs/vajra-ui-core. Use useVajraTheme. Only imports from @devraj-labs/vajra-ui-core. |
| Components | src/ui/components/ | Opinionated components with variants, constants, and optional hooks. Only imports from src/ui/core — never @devraj-labs/vajra-ui-core directly. |
| Theme | src/theme/ | Generic ThemeProvider + useTheme. No vajra-specific tokens. |
| Vajra Theme | src/ui/vajra-theme/ | Default vajra tokens. VajraProvider, useVajraTheme, defaultVajraTheme. |
src/theme vs src/ui/vajra-theme
These are not two competing theme systems — src/theme is the generic engine, src/ui/vajra-theme is Vajra's specific theme built on top of it.
src/theme/is a bare React context mechanism:ThemeProvider,useTheme<T>(), and an intentionally emptyVajraThemeinterface (provider-types.ts) meant to be filled in by whatever sits on top. It has no colors, spacing, or token knowledge of its own, and no consumer should reach for it directly — it's internal plumbing.src/ui/vajra-theme/consumessrc/themeinternally:VajraProvider(vajra-provider.tsx) wraps the genericThemeProviderand feeds itdefaultVajraTheme/acreateVajraTheme(...)result;useVajraTheme()(use-vajra-theme.ts) is a thin typed wrapper around the genericuseTheme(). This is the layer components and consumers actually use.- Rule: only
src/ui/vajra-theme/**may import fromsrc/theme/**. Nothing else — notsrc/ui/core, notsrc/ui/components— should importsrc/themedirectly; they go throughuseVajraTheme/VajraProviderinstead.
Optional Motion Dependencies (react-native-reanimated, react-native-gesture-handler)
Vajra UI is a zero-required-dependency library — nothing beyond react/react-native/react-native-safe-area-context is mandatory to install it. Some components (bottom sheets, drag-to-dismiss toasts, animated accordions) are meaningfully better with react-native-reanimated and react-native-gesture-handler, but requiring them for every consumer would break that promise and add real native-linking weight to apps that don't need motion. The rule:
- List them as
optionalDependencies/ optionalpeerDependenciesinpackage.json(usepeerDependenciesMeta: { "react-native-reanimated": { "optional": true } }alongside thepeerDependenciesentry) — never as a harddependency. - Never
importthem statically at module scope in a component file. A static top-level import throws immediately for any consumer who hasn't installed the optional package, even if they never render that component. Use a lazy, guarded require inside the component/hook instead:// <name>/use-<name>-motion.tslet Reanimated: typeof import('react-native-reanimated') | null = null;try {// eslint-disable-next-line @typescript-eslint/no-var-requiresReanimated = require('react-native-reanimated');} catch {Reanimated = null;}export const hasReanimated = Reanimated !== null; - Every motion-dependent component must have a working fallback path when the optional package isn't installed — not a thrown error. E.g.
Sheetfalls back to an instant show/hide (no slide animation, no gesture-driven drag-to-dismiss, tap-outside/close-button still work);Toastfalls back to instant appear/disappear instead of an animated slide-in. - Document the enhancement in that component's usage docs: "Install
react-native-reanimated(andreact-native-gesture-handlerfor drag-to-dismiss) for animated/gesture behavior — works without them, with reduced motion." - Write tests for both paths: one with the optional module mocked as present, one with it mocked as absent (
jest.mock('react-native-reanimated', () => { throw new Error(); })or similar), asserting the component still renders and functions in the fallback case.