Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/eight-colts-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clickhouse/click-ui': minor
---

This library will now use CSS Modules for styling and is distributed unbundled, giving your application full control over bundling and optimisations. This means you only include what you actually use, resulting in smaller bundle sizes and better performance!

NOTE: We're currently migrating from Styled-Components to CSS Modules. Some components may still use Styled-Components during this transition period.

To learn more about CSS modules support, check our documentation [here](https://github.com/ClickHouse/click-ui?tab=readme-ov-file#css-modules)
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ You can find the official docs for the Click UI design system and component libr
* [Distribution](#distribution)
- [Build](#build)
- [Use Click UI](#use-click-ui)
- [CSS Modules](#css-modules)
- [Deep imports support](#deep-imports-support)
- [Examples](#examples)
- [Releases and Versions](#releases-and-versions)
Expand Down Expand Up @@ -217,6 +218,40 @@ export default () => {

To learn more about individual components, visit [Click UI components](https://clickhouse.design/click-ui).

### CSS Modules

This library uses [CSS Modules](https://github.com/css-modules/css-modules) for styling and is distributed unbundled, giving your application full control over bundling and optimizations. This means you only include what you actually use, resulting in smaller bundle sizes and better performance!

Most modern React frameworks support CSS Modules out of the box, including Next.js, Vite, Create React App, and TanStack Start, with no configuration required.

> [!NOTE]
> We're currently migrating from Styled-Components to CSS Modules. Some components may still use Styled-Components during this transition period.

#### Benefits

CSS Modules align naturally with component-level imports. When you import a component like `Button`, its `Button.module.css` is automatically included. If you don't use the component, neither the JavaScript, or CSS will be bundled in your application's output. Only the necessary stylesheets will be included in the output bundle.

#### Custom Build Configurations

Although most modern React setups have CSS Modules built-in, if your build tool doesn't support it by default, you'll need to configure it.

Let's assume you have an old Webpack setup. Here's an example of how that'd look like:

```js
{
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: { modules: true }
}
]
}
```

For other bundlers, refer to their documentation on CSS Modules configuration.

### Deep imports support

Deep imports are supported, you can import directly from path.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"vite": "^7.3.0",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-externalize-deps": "^0.10.0",
"vite-plugin-static-copy": "^3.2.0",
"vite-tsconfig-paths": "^6.0.5",
"vitest": "^2.1.8",
"watch": "^1.0.2"
Expand Down
1 change: 1 addition & 0 deletions src/components/Button/Button.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.demoCSSModuleSetupOnlyDeleteAfter {}
3 changes: 3 additions & 0 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { styled, keyframes } from 'styled-components';
import { BaseButton } from '../commonElement';
import React from 'react';

import styles from './Button.module.css';

export type ButtonType = 'primary' | 'secondary' | 'empty' | 'danger';
type Alignment = 'center' | 'left';

Expand Down Expand Up @@ -42,6 +44,7 @@ export const Button = ({
...delegated
}: ButtonProps) => (
<StyledButton
className={styles.demoCSSModuleSetupOnlyDeleteAfter}
$styleType={type}
$align={align}
$fillWidth={fillWidth}
Expand Down
109 changes: 43 additions & 66 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ import dts from 'vite-plugin-dts';
import { externalizeDeps } from 'vite-plugin-externalize-deps';
import tsconfigPaths from 'vite-tsconfig-paths';
import { visualizer } from 'rollup-plugin-visualizer';
import { viteStaticCopy } from 'vite-plugin-static-copy';

const srcDir = path.resolve(__dirname, 'src').replace(/\\/g, '/');

const buildOptions: BuildOptions = {
// TODO: Find a solution for static files based on conf extensions
const cssExternalPlugin = () => ({
name: 'css-external',
enforce: 'pre' as const,
resolveId: (id: string) => (id.endsWith('.module.css') ? { id, external: true } : null),
});

const build: BuildOptions = {
target: 'esnext',
emptyOutDir: true,
// WARNING: Do not minify unbundled builds
Expand All @@ -18,7 +26,7 @@ const buildOptions: BuildOptions = {
// which makes static analysis challenging
minify: false,
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
entry: `${srcDir}/index.ts`,
},
rollupOptions: {
output: [
Expand Down Expand Up @@ -51,6 +59,7 @@ const buildOptions: BuildOptions = {
const viteConfig = defineConfig({
publicDir: false,
plugins: [
cssExternalPlugin(),
react({
babel: {
plugins: [['babel-plugin-styled-components', { displayName: false }]],
Expand Down Expand Up @@ -85,6 +94,37 @@ const viteConfig = defineConfig({
useFile: path.join(process.cwd(), 'package.json'),
}),
tsconfigPaths(),
// TODO: Copying CSS Module files to both esm and cjs dist directories should have the target names, e.g. esm, cjs shared with bundled target, so that they're automatically sync.
viteStaticCopy({
targets: [
{
src: 'src/**/*.module.css',
dest: 'esm',
rename: (fileName: string, fileExt: string, srcPath: string) => {
const srcIndex = srcPath.indexOf('/src/');
const ext = fileExt.startsWith('.') ? fileExt : `.${fileExt}`;
if (srcIndex !== -1) {
const relativePath = srcPath.slice(srcIndex + 5, srcPath.lastIndexOf('/'));
return `${relativePath}/${fileName}${ext}`;
}
return `${fileName}${ext}`;
},
},
{
src: 'src/**/*.module.css',
dest: 'cjs',
rename: (fileName: string, fileExt: string, srcPath: string) => {
const srcIndex = srcPath.indexOf('/src/');
const ext = fileExt.startsWith('.') ? fileExt : `.${fileExt}`;
if (srcIndex !== -1) {
const relativePath = srcPath.slice(srcIndex + 5, srcPath.lastIndexOf('/'));
return `${relativePath}/${fileName}${ext}`;
}
return `${fileName}${ext}`;
},
},
],
}),
// WARNING: Keep the visualizer last
...(process.env.ANALYZE === 'true'
? [
Expand All @@ -97,70 +137,7 @@ const viteConfig = defineConfig({
]
: []),
],
css: {
preprocessorOptions: {
scss: {
// Auto-inject tokens import in all SCSS files
// Components can directly use: tokens.$clickGlobalColorBackgroundDefault
additionalData: `@use "${srcDir}/styles/tokens-light-dark.scss" as tokens;\n`,
},
},
postcss: {
plugins: [
{
// Wrap only CSS custom properties in @layer for easy consumer override
postcssPlugin: 'wrap-tokens-in-layer',
Once(root, { AtRule }) {
// 1. Add layer declaration for tokens at the top
const layerDeclaration = new AtRule({
name: 'layer',
params: 'click-ui.tokens',
});
root.prepend(layerDeclaration);

// 2. Find and wrap only :root rules with CSS custom properties
const tokenRules = [];
const otherNodes = [];

root.each(node => {
if (node === layerDeclaration) {
return; // Skip the layer declaration itself
}

if (node.type === 'rule' && node.selector === ':root') {
// Check if it contains CSS custom properties
const hasCustomProps = node.nodes?.some(
child => child.type === 'decl' && child.prop.startsWith('--')
);
if (hasCustomProps) {
tokenRules.push(node.clone());
node.remove();
return;
}
}

// Keep all other nodes as-is (component classes stay unlayered)
otherNodes.push(node);
});

// 3. Wrap tokens in @layer click-ui.tokens
if (tokenRules.length > 0) {
const tokensLayer = new AtRule({
name: 'layer',
params: 'click-ui.tokens',
});
tokenRules.forEach(rule => tokensLayer.append(rule));
root.append(tokensLayer);
}

// 4. Component styles stay unlayered (normal priority)
// This allows consumers to override with regular CSS
},
},
],
},
},
build: buildOptions,
build,
});

const vitestConfig = defineVitestConfig({
Expand Down
Loading
Loading