JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 421
  • Score
    100M100P100Q108021F
  • License MIT

A react tool to separate class name logic, create variants and manage styles.

Package Exports

  • @classmatejs/react
  • @classmatejs/react/package.json

Readme

@classmatejs/react

npm npm bundle size

A tool for managing React component class names, variants and styles.

Installation

npm install @classmatejs/react
# or
yarn add @classmatejs/react

Documentation

Contents

Getting started

Requires React >= 16.8.0. Once installed, start creating components with the cm factory:

Basic usage

Create a component by calling cm with a tag name and a template literal string.

import cm from "@classmatejs/react";

const Container = cm.div`
  py-2
  px-5
  min-h-24
`;
// transforms to: <div className="py-2 px-5 min-h-24" />

Additional Information: See "Basic usage" documentation

Use with props

Pass props to the component and use them in the template literal string and in the component prop validation.

// hey typescript
interface ButtonProps {
  $isActive?: boolean;
  $isLoading?: boolean;
}
const SomeButton = cm.button<ButtonProps>`
  text-lg
  mt-5
  ${(
  p,
) => (p.$isActive ? "bg-blue-400 text-white" : "bg-blue-400 text-blue-200")}
  ${(p) => (p.$isLoading ? "opacity-90 pointer-events-none" : "")}
`;
// transforms to <button className="text-lg mt-5 bg-blue-400 text-white opacity-90 pointer-events-none" />

Prefix incoming props with $

we prefix the props incoming to dc with a $ sign. This is a important convention to distinguish dynamic props from the ones we pass to the component.

This pattern should also avoid conflicts with reserved prop names.

Create Variants

Create variants by passing an object to the variants key like in cva. The key should match the prop name and the value should be a function that returns a string. You could also re-use the props in the function.

interface AlertProps {
  $severity: "info" | "warning" | "error";
  $isActive?: boolean;
}
const Alert = cm.div.variants<AlertProps>({
  // optional
  base: (p) => `
    ${p.isActive ? "custom-active" : "custom-inactive"}
    p-4
    rounded-md
  `,
  // required
  variants: {
    $severity: {
      warning: "bg-yellow-100 text-yellow-800",
      info: (p) =>
        `bg-blue-100 text-blue-800 ${p.$isActive ? "shadow-lg" : ""}`,
      error: (p) =>
        `bg-red-100 text-red-800 ${p.$isActive ? "ring ring-red-500" : ""}`,
    },
  },
  // optional - used if no variant was found
  defaultVariant: {
    $severity: "info",
  },
});

export default () => <Alert $severity="info" $isActive />;
// outputs: <div className="custom-active p-4 rounded-md bg-blue-100 text-blue-800 shadow-lg" />

Additional Information: See "Variants" documentation

Typescript: Separate base props and variants with a second type parameter

As seen above, we also pass AlertProps to the variants, which can cause loose types. If you want to separate the base props from the variants, you can pass a second type to the variants function so that only those props are available in the variants.

interface AlertProps {
  $isActive?: boolean;
}
interface AlertVariants {
  $severity: "info" | "warning" | "error";
}
const Alert = cm.div.variants<AlertProps, AlertVariants>({
  base: `p-4 rounded-md`,
  variants: {
    // in here there are only the keys from AlertVariants available
    $severity: {
      // you can use the props from AlertProps here again
      warning: "bg-yellow-100 text-yellow-800",
      info: (p) =>
        `bg-blue-100 text-blue-800 ${p.$isActive ? "shadow-lg" : ""}`,
      error: (p) =>
        `bg-red-100 text-red-800 ${p.$isActive ? "ring ring-red-500" : ""}`,
    },
  },
  // optional - used if no variant was found
  defaultVariant: {
    $severity: "info",
  },
});

Extend

Extend a component directly by passing the component and the tag name.

import MyOtherComponent from "./MyOtherComponent"; // () => <button className="text-lg mt-5" />
import cm from "/react";

const Container = cm.extend(MyOtherComponent)`
  py-2
  px-5
  min-h-24
`;
// transforms to: <button className="text-lg mt-5 py-2 px-5 min-h-24" />

Additional Information: "Extend" documentation

Extend with variants

You can also generate a brand-new variant map on top of an extended component. This is handy when the shared base styles live in template literals, but you still want a declarative API for the final consumer.

interface ButtonProps {
  $isLoading?: boolean
}

const BaseButton = cm.button<ButtonProps>`
  font-semibold
  rounded-md
  ${(p) => (p.$isLoading ? "opacity-50" : "opacity-100")}
`

interface VariantExtras extends ButtonProps {
  $tone?: "muted" | "loud"
}

type VariantConfigProps = VariantExtras & ButtonHTMLAttributes<HTMLButtonElement>

const AccentButton = cm.extend(BaseButton).variants<VariantConfigProps, { $size: "sm" | "lg" }>({
  base: ({ $tone, $isLoading }) => `
    ${$tone === "muted" ? "text-slate-500" : "text-white"}
    ${$isLoading ? "pointer-events-none" : ""}
  `,
  variants: {
    $size: {
      sm: "text-sm px-3 py-1.5",
      lg: ({ $isLoading }) => `text-lg px-5 py-3 ${$isLoading ? "cursor-wait" : ""}`,
    },
  },
  defaultVariants: {
    $size: "sm",
  },
})

AccentButton keeps the logic/styles from BaseButton, filters variant props from the DOM, and offers a variants-only API to downstream components.

Add CSS Styles

You can use CSS styles in the template literal string with the style function. This function takes an object with CSS properties and returns a string. We can use the props from before.

// Base:
const StyledButton = cm.button<{ $isDisabled: boolean }>`
  text-blue
  ${(p) =>
  p.style({
    boxShadow: "0 0 5px rgba(0, 0, 0, 0.1)",
    cursor: p.$isDisabled ? "not-allowed" : "pointer",
  })}
`;
export default () => <StyledButton $isDisabled />;
// outputs: <button className="text-blue" style="box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); cursor: not-allowed;" />
// Extended:
const BaseButton = cm.button<{ $isActive?: boolean }>`
  ${(p) =>
  p.style({
    backgroundColor: p.$isActive ? "green" : "red",
  })}
`;
const ExtendedButton = cm.extend(BaseButton)<{ $isLoading?: boolean }>`
  ${(p) =>
  p.style({
    opacity: p.$isLoading ? 0.5 : 1,
    pointerEvents: p.$isLoading ? "none" : "auto",
  })}
`;
export default () => <ExtendedButton $isActive $isLoading />;
// outputs: <button className="bg-red" style="opacity: 0.5; pointer-events: none;" />

Use inside React components - useClassmate

When you need to create a classmate component inside another React component (for example, when the configuration depends on runtime-only values), wrap the factory with useClassmate. This memoizes the result and avoids creating a brand-new component on every render.

import cm, { useClassmate } from "/react";

const WorkoutDay = ({ status }: { status: "completed" | "pending" }) => {
  const StyledDay = useClassmate(
    () =>
      cm.div.variants({
        base: "rounded border p-4 text-sm",
        variants: {
          $status: {
            completed: "border-green-400 bg-green-50",
            pending: "border-yellow-400 bg-yellow-50",
          },
        },
      }),
    [status], // recompute when dependencies change
  );

  return <StyledDay $status={status}>Workout details</StyledDay>;
};

The dependency array behaves like React.useMemo. Pass everything the factory closes over if you expect the component to update when those values change.

Add logic headers

Use .logic() to run arbitrary JavaScript once per render before your class names or variants are computed. The return value is shallow-merged back into the props, so you can derive $ props, DOM attributes, or anything else your component needs without additional hooks.

type DayStatus = "completed" | "pending"

interface WorkoutProps {
  workouts: unknown[]
  allResolved: boolean
  hasCompleted: boolean
  hasSkipped: boolean
  $status?: DayStatus
}

const WorkoutDay = cm.div
  .logic<WorkoutProps>((props) => {
    const status = deriveDayStatus(props)
    return {
      $status: status,
      ["data-status"]: status,
    }
  })
  .variants<WorkoutProps, { $status: DayStatus }>({
    base: "rounded border p-4",
    variants: {
      $status: {
        completed: "bg-green-50 border-green-400",
        pending: "bg-white border-slate-200",
      },
    },
  })

// Consumers only pass raw workout data – the logic header derives $status for you.
<WorkoutDay workouts={workouts} allResolved hasCompleted hasSkipped={false} />

Return values from .logic() are merged in order, so later logic calls can reference earlier results or override them.

Recipes for cm.extend

With cm.extend, you can build upon any base React component, adding new styles and even supporting additional props. This makes it easy to create reusable component variations without duplicating logic.

import { ArrowBigDown } from "lucide-react";
import cm from "/react";

const StyledLucideArrow = cm.extend(ArrowBigDown)`
  md:-right-4.5
  right-1
  slide-in-r-20
`;

// ts: we can pass only props which are accessible on a `lucid-react` Component
export default () => <StyledLucideArrow stroke="3" />;

⚠️ Having problems by extending third party components, see: Extending other lib components

Now we can define a base component, extend it with additional styles and classes, and pass properties. You can pass the types to the extend function to get autocompletion and type checking.

import cm from "/react";

interface StyledSliderItemBaseProps {
  $active: boolean;
}
const StyledSliderItemBase = cm.button<StyledSliderItemBaseProps>`
  absolute
  h-full
  w-full
  left-0
  top-0
  ${(p) => (p.$active ? "animate-in fade-in" : "animate-out fade-out")}
`;

interface NewStyledSliderItemProps extends StyledSliderItemBaseProps {
  $secondBool: boolean;
}
const NewStyledSliderItemWithNewProps = cm.extend(
  StyledSliderItemBase,
)<NewStyledSliderItemProps>`
  rounded-lg
  text-lg
  ${(p) => (p.$active ? "bg-blue" : "bg-red")}
  ${(p) => (p.$secondBool ? "text-underline" : "some-class-here")}
`;

export default () => (
  <NewStyledSliderItemWithNewProps $active $secondBool={false} />
);
// outputs: <button className="absolute h-full w-full left-0 top-0 animate-in fade-in rounded-lg text-lg bg-blue" />

Use cm for creating base component

const BaseButton = cm.extend(cm.button``)`
  text-lg
  mt-5
`;

extend from variants

interface ButtonProps extends InputHTMLAttributes<HTMLInputElement> {
  $severity: "info" | "warning" | "error";
  $isActive?: boolean;
}

const Alert = cm.input.variants<ButtonProps>({
  base: "p-4",
  variants: {
    $severity: {
      info: (p) =>
        `bg-blue-100 text-blue-800 ${p.$isActive ? "shadow-lg" : ""}`,
    },
  },
});

const ExtendedButton = cm.extend(Alert)<{ $test: boolean }>`
  ${(p) => (p.$test ? "bg-green-100 text-green-800" : "")}
`;

export default () => <ExtendedButton $severity="info" $test />;
// outputs: <input className="p-4 bg-blue-100 text-blue-800 shadow-lg bg-green-100 text-green-800" />

Auto infer types for props

By passing the component, we can validate the component to accept tag related props. This is useful if you wanna rely on the props for a specific element without the $ prefix.

// if you pass cm component it's types are validated
const ExtendedButton = cm.extend(cm.button``)`
  some-class
  ${(p) => (p.type === "submit" ? "font-normal" : "font-bold")}
`;

// infers the type of the input element + add new props
const MyInput = ({ ...props }: HTMLAttributes<HTMLInputElement>) => (
  <input {...props} />
);
const StyledDiv = cm.extend(MyInput)<{ $trigger?: boolean }>`
  bg-white
  ${(p) => (p.$trigger ? "!border-error" : "")}
  ${(p) => (p.type === "submit" ? "font-normal" : "font-bold")}
`;

Extending other lib components / any as Input

Unfortunately we cannot infer the type directly of the component if it's any or loosely typed. But we can use a intermediate step to pass the type to the extend function.

import { ComponentProps } from 'react'
import { MapContainer } from 'react-leaflet'
import { Field, FieldConfig } from 'formik'
import cm, { CmBaseComponent } from '/react'

// we need to cast the type to ComponentProps
type StyledMapContainerType = ComponentProps<typeof MapContainer>
const StyledMapContainer: CmBaseComponent<StyledMapContainerType> = cm.extend(MapContainer)`
  absolute
  h-full
  w-full
  text-white
  outline-0
`

export const Component = () => <StyledMapContainer bounds={...} />

// or with Formik

import { Field, FieldConfig } from 'formik'

type FieldComponentProps = ComponentProps<'input'> & FieldConfig
const FieldComponent = ({ ...props }: FieldComponentProps) => <Field {...props} />

const StyledField = cm.extend(FieldComponent)<{ $error: boolean }>`
  theme-form-field
  w-full
  ....
  ${p => (p.$error ? '!border-error' : '')}
`

export const Component = () => <StyledField placeholder="placeholder" as="select" name="name" $error />

⚠️ This is a workaround! This is a bug - we should be able to pass the types directly in the interface in which we pass $error. Contributions welcome.

CommonJS

If you are using CommonJS, you can import the library like this:

const cm = require("/react").default;

// or

const { default: cm } = require("/react");