@classmatejs/solid
v0.2.1
Published
A solidjs tool to separate class name logic, create variants and manage styles.
Maintainers
Readme
@classmatejs/solid
A tool for managing React component class names, variants and styles.
🚩 Transform this
const SomeButton = ({ isLoading, ...props }) => {
const activeClass = createMemo(() => isLoading
? "bg-blue-400 text-white"
: "bg-blue-800 text-blue-200";
)
return (
<button
{...props}
class={`transition-all mt-5 border-1 md:text-lg text-normal ${someConfig.transitionDurationEaseClass} ${activeClass} ${
props.class || ""
}`}
>
{props.children}
</button>
);
};🌤️ Into
const SomeButton = cm.button`
text-normal
md:text-lg
mt-5
border-1
transition-all
${someConfig.transitionDurationEaseClass}
${({ $isLoading }) => $isLoading && "opacity-90 pointer-events-none"}
`;Features
- Class name-focused components
- Variants
- Extend components
- Dynamic styles
- TypeScript support
- Tested with SSR Frameworks
- Classname merging
Contents
- Features
- Getting started
- Basic usage
- Usage with props
- Create Variants
- Extend components
- Add CSS Styles
- Use inside Solid components
- Add logic headers
- Recipes for
cm.extend
Getting started
Make sure you have installed SolidJS (> 1.8.0) in your project.
npm i @classmatejs/solid
# or
yarn add @classmatejs/solidBasic
Create a component by calling cm with a tag name and a template literal
string.
import cm from "@classmatejs/solid";
const Container = cm.div`
py-2
px-5
min-h-24
`;
// transforms to: <div className="py-2 px-5 min-h-24" />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
${({ $isActive }) => ($isActive ? "bg-blue-400 text-white" : "bg-blue-400 text-blue-200")}
${({ $isLoading }) => $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: ({ $isActive }) =>
`bg-blue-100 text-blue-800 ${ $isActive ? "shadow-lg" : ""}`,
error: ({ $isActive }) =>
`bg-red-100 text-red-800 ${ $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" />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 "@classmatejs/solid";
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" />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 Solid components - createClassmate
Solid components only execute once, so you can safely declare classmate
components inline. When you still want a helper to encapsulate that factory,
createClassmate simply evaluates the callback and returns the generated
component.
import cm, { createClassmate } from "@classmatejs/solid";
const WorkoutDay = ({ status }: { status: "completed" | "pending" }) => {
const StyledDay = createClassmate(() =>
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",
},
},
})
);
return <StyledDay $status={status}>Workout details</StyledDay>;
};The helper mirrors the legacy useClassmate API but without dependency tracking
since Solid props are already stable.
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 Solid 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-solid";
import cm from "@classmatejs/solid";
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 `lucide-solid` 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 "@classmatejs/solid";
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" />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 sc 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 }: JSX.InputHTMLAttributes<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 type { ComponentProps } from "solid-js";
import { MapView } from "solid-awesome-map";
import { Field, type FieldProps } from "@modular-forms/solid";
import cm, { CmBaseComponent } from "@classmatejs/solid";
// we need to cast the type to ComponentProps
type StyledMapProps = ComponentProps<typeof MapView>;
const StyledMap: CmBaseComponent<StyledMapProps> = cm.extend(MapView)`
absolute
h-full
w-full
text-white
outline-0
`;
export const Component = () => <StyledMap bounds={...} />;
// or with another Solid form library
type FieldComponentProps = ComponentProps<typeof Field> & FieldProps;
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" 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("@classmatejs/solid").default;
// or
const { default: sc } = require("@classmatejs/solid");Tailwind Merge
solid-classmate uses tailwind-merge under the hood to merge class names. The last class name will always win, so you can use it to override classes.
Upcoming
- bug / troubleshoot: classnames set by ref.current (useRef) will be overwritten
as soon component rerenders
- needs at least a small article in the docs
cm.raw()andcm.raw.variants()for only usingscsyntax for classnames (output as string)- Variants for
cm.extend - named lib import for CommonJS (currently only
.default) -- Means we need to remove the named export in the ts file to not duplicate IDE import suggestions: --- Change postbuild script to remove named esm export - Integrate more tests, benchmarks focused on SSR and Solid
- Advanced IDE integration
- show generated default class on hover
- enforce autocompletion and tooltips from the used libs
