Skip to content
Merged
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/fuzzy-pixels-coordinate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@react-pixel-ui/core": patch
"@react-pixel-ui/react": patch
---

Preserve existing parent filters when `usePixelRef` renders shadows, coordinate
filters shared by sibling instances, and reuse one root theme observer.

Correct the published package documentation and PNG terminology.
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,56 @@ jobs:

- name: Build
run: pnpm build

compatibility:
name: Package compatibility
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup Node.js 22
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false

- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build packages and demo
run: pnpm build

- name: Test React 18, React 19, and Next.js
run: pnpm test:compat

browser:
name: Browser compatibility
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup Node.js 22
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false

- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build packages and prerendered demo
run: pnpm build

- name: Install browser runtimes
run: pnpm exec playwright install --with-deps chromium firefox webkit

- name: Run browser tests
run: pnpm test:e2e
19 changes: 10 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ on:

concurrency: ${{ github.workflow }}-${{ github.ref }}

permissions:
contents: write
pull-requests: write
id-token: write

jobs:
release:
name: Release
Expand All @@ -15,10 +20,10 @@ jobs:
- name: Checkout Repo
uses: actions/checkout@v5

- name: Setup Node.js 20.x
- name: Setup Node.js 22.x
uses: actions/setup-node@v5
with:
node-version: 20
node-version: 22
registry-url: "https://registry.npmjs.org"
# pnpm comes from corepack in the next step; without this,
# setup-node@v5 tries to locate pnpm for caching and fails
Expand All @@ -27,14 +32,12 @@ jobs:
- name: Enable Corepack
run: corepack enable

- name: Install npm with trusted publishing support
run: npm install --global "npm@^11.5.1"

- name: Install Dependencies
run: pnpm install --frozen-lockfile

- name: Create .npmrc
run: |
echo email=rhymint@gmail.com > ~/.npmrc
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc

- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
Expand All @@ -43,5 +46,3 @@ jobs:
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![bundle size](https://img.shields.io/bundlephobia/minzip/@react-pixel-ui/react)](https://bundlephobia.com/package/@react-pixel-ui/react)
[![license](https://img.shields.io/npm/l/@react-pixel-ui/react)](https://github.com/Todari/react-pixel-ui/blob/main/LICENSE)

Any CSS to pixel art. Wrap your element with `<Pixel>` — Tailwind, inline styles, CSS modules all work. No Canvas, SSR compatible.
Turn supported CSS into pixel art. Wrap one rendered HTML element with `<Pixel>` — Tailwind, inline styles, and CSS modules work without Canvas and remain SSR compatible.

[Demo](https://react-pixel-ui.vercel.app) | [npm](https://www.npmjs.com/package/@react-pixel-ui/react) | [GitHub](https://github.com/Todari/react-pixel-ui)

Expand Down Expand Up @@ -69,7 +69,7 @@ import { Pixel } from '@react-pixel-ui/react';
|------|------|---------|-------------|
| `size` | `number` | `4` | Pixel block size in CSS px. Larger = blockier. |
| `enabled` | `boolean` | `true` | Toggle pixelation on/off |
| `children` | `ReactElement` | required | Single child element to pixelate |
| `children` | `ReactElement` | required | Single HTML element, or a component that forwards its ref to one |

**Supported CSS properties:**
- `background` / `background-color` — solid colors and gradients (`linear-gradient`, `radial-gradient`, `repeating-*`). Alpha-preserving.
Expand All @@ -82,6 +82,11 @@ import { Pixel } from '@react-pixel-ui/react';

Attach to any element without wrapping. Best for third-party components or when you can't use a wrapper.

When a pixel shadow is present, the hook composes the generated
`drop-shadow()` on the element's parent so the child's staircase clip does
not cut it off. Existing parent filters are preserved, and multiple managed
children share the parent safely.

```tsx
import { usePixelRef } from '@react-pixel-ui/react';

Expand Down Expand Up @@ -132,7 +137,7 @@ function App() {
| Config Key | Type | Default | Description |
|------------|------|---------|-------------|
| `pixelSize` | `number` | `4` | Default pixel block size |
| `borderColor` | `string` | — | Default border color |
| `borderColor` | `string` | — | Default for `PixelBox`; CSS-reading APIs use the computed border color |

### `PixelBox` — Explicit props

Expand Down Expand Up @@ -363,6 +368,11 @@ A: Yes. The core package uses pure math (no Canvas, no DOM APIs). Elements rende
the child via descendant selectors, trigger a parent re-render or use
`usePixelRef`, which listens to `style` mutations on the managed element
directly in addition to hover / focus / active / resize.
- **`usePixelRef` shadows and parent filters**: hard shadows are composed on
the managed element's parent to avoid clipping. The hook preserves
existing filters and coordinates sibling instances, but CSS selectors
that expect the parent's `filter` to be exactly `none` may still need an
isolated wrapper.

## Browser Compatibility

Expand All @@ -372,8 +382,6 @@ A: Yes. The core package uses pure math (no Canvas, no DOM APIs). Elements rende
| `image-rendering: pixelated` | 41+ | 56+ (`crisp-edges`) | 10+ | 79+ |
| `filter: drop-shadow()` | 18+ | 35+ | 6+ | 79+ |

**Overall: 97%+** global browser coverage.

## TypeScript

Fully typed. All components, hooks, and config objects have TypeScript definitions.
Expand All @@ -391,7 +399,7 @@ import type {

```
packages/
core/ # Pure CSS generators (zero browser dependency, SSR safe)
core/ # Framework-agnostic style generators (zero browser dependency, SSR safe)
react/ # React hooks & components
apps/
demo/ # Interactive demo + documentation site
Expand Down
5 changes: 0 additions & 5 deletions apps/demo/.eslintrc.js

This file was deleted.

55 changes: 55 additions & 0 deletions apps/demo/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect, test } from "@playwright/test";

test("renders the landing page with metadata and accessible controls", async ({
page,
}) => {
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});

await page.goto("/");

await expect(
page.getByRole("heading", { level: 1, name: "React Pixel UI" }),
).toBeVisible();
await expect(page.locator(".hero-badge")).toHaveText(/^v\d+\.\d+\.\d+$/);
await expect(
page.getByRole("slider", { name: "Pixel Size", exact: true }),
).toBeVisible();
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
"https://react-pixel-ui.vercel.app/og.png",
);

const slider = page.getByRole("slider", { name: "Pixel Size", exact: true });
await slider.focus();
await expect(slider).toBeFocused();
expect(
await slider.evaluate((element) => getComputedStyle(element).outlineStyle),
).not.toBe("none");

await page
.getByRole("button", { name: "Copy code to clipboard" })
.first()
.click();
await expect(
page.getByRole("button", { name: "Copy code to clipboard" }).first(),
).toHaveText("Copied");
expect(consoleErrors).toEqual([]);
});

test("does not overflow a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto("/");

const dimensions = await page.evaluate(() => ({
viewport: document.documentElement.clientWidth,
content: document.documentElement.scrollWidth,
}));

expect(dimensions.content).toBeLessThanOrEqual(dimensions.viewport);
await expect(
page.getByRole("link", { name: "Try playground" }),
).toBeVisible();
});
1 change: 1 addition & 0 deletions apps/demo/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@react-pixel-ui/eslint-config/react');
15 changes: 12 additions & 3 deletions apps/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Pixel UI - CSS Pixel Art Library for React</title>
<meta name="description" content="Create retro pixel art UI with React. Pure CSS output using clip-path, RGBA PNG gradients, and drop-shadow. No Canvas dependency, SSR compatible." />
<meta name="description" content="Create retro pixel art UI with React. DOM and CSS output using clip-path, generated RGBA PNG gradients, and drop-shadow. No Canvas dependency, SSR compatible." />
<meta name="keywords" content="react, pixel, pixel-art, pixelated, css, clip-path, retro, ui, react component, library, SSR" />
<meta name="author" content="Todari" />
<link rel="canonical" href="https://react-pixel-ui.vercel.app/" />
Expand All @@ -14,13 +14,22 @@
<meta property="og:site_name" content="React Pixel UI" />
<meta property="og:title" content="React Pixel UI - CSS Pixel Art Library" />
<meta property="og:description" content="Wrap any element with &lt;Pixel&gt; and your CSS becomes pixel art. Tailwind, inline styles, CSS modules — all supported. No Canvas, SSR compatible." />
<meta property="og:image" content="https://react-pixel-ui.vercel.app/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="React Pixel UI — Any CSS to pixel art" />
<meta property="og:locale" content="en_US" />

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="React Pixel UI - CSS Pixel Art Library" />
<meta name="twitter:description" content="Wrap any element with <Pixel> and your CSS becomes pixel art. No Canvas, SSR compatible." />
<meta name="twitter:description" content="Wrap any element with &lt;Pixel&gt; and your CSS becomes pixel art. No Canvas, SSR compatible." />
<meta name="twitter:image" content="https://react-pixel-ui.vercel.app/og.png" />
<meta name="twitter:image:alt" content="React Pixel UI — Any CSS to pixel art" />

<meta name="theme-color" content="#ff8906" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=Press+Start+2P&amp;display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect fill='%23ff8906' x='0' y='0' width='8' height='8'/><rect fill='%23e53170' x='8' y='0' width='8' height='8'/><rect fill='%236c5ce7' x='0' y='8' width='8' height='8'/><rect fill='%2300b894' x='8' y='8' width='8' height='8'/></svg>" />
<link rel="apple-touch-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect fill='%23ff8906' x='0' y='0' width='8' height='8'/><rect fill='%23e53170' x='8' y='0' width='8' height='8'/><rect fill='%236c5ce7' x='0' y='8' width='8' height='8'/><rect fill='%2300b894' x='8' y='8' width='8' height='8'/></svg>" />

Expand All @@ -33,7 +42,7 @@
"@id": "https://react-pixel-ui.vercel.app/#website",
"url": "https://react-pixel-ui.vercel.app/",
"name": "React Pixel UI",
"description": "Pure CSS pixel art library for React — no Canvas, SSR compatible.",
"description": "DOM and CSS pixel art library for React — no Canvas, SSR compatible.",
"inLanguage": "en-US",
"publisher": { "@id": "https://react-pixel-ui.vercel.app/#person" }
},
Expand Down
15 changes: 9 additions & 6 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
"description": "Demo app for React Pixel UI",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "pnpm build:client && pnpm build:ssr && pnpm prerender",
"build:client": "vite build",
"build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist/.ssr",
"prerender": "node scripts/prerender.mjs",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
Expand All @@ -21,8 +24,8 @@
"@react-pixel-ui/eslint-config": "workspace:*",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
"vite": "^6.4.3"
}
}
}
10 changes: 5 additions & 5 deletions apps/demo/public/llms.txt
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
# React Pixel UI

> React Pixel UI is a pure-CSS pixel art component library for React. Wrap any element with the `<Pixel>` component and its rendered output is automatically transformed into a low-resolution pixel-art look — no Canvas, no pre-rendering, SSR compatible.
> React Pixel UI is a DOM/CSS pixel-art component library for React. Wrap one rendered HTML element with `<Pixel>` and its supported computed styles are transformed into a low-resolution pixel-art look — no Canvas and SSR compatible.

## Why pure CSS

The pixelation is achieved with a combination of `clip-path`, RGBA PNG gradient masks, and `drop-shadow` filters — meaning the output is real DOM/CSS, so it works under server-side rendering and is accessible.
The pixelation uses `clip-path`, generated RGBA PNG data URLs, and `drop-shadow` filters. The child remains a real DOM element; it renders normally on the server and is enhanced after hydration.

## Usage

```jsx
import { Pixel } from "@react-pixel-ui/react";

<Pixel pixelSize={4}>
<Pixel size={4}>
<button className="bg-orange-500 text-white p-4 rounded">
Click me
</button>
</Pixel>
```

Anything inside `<Pixel>` (Tailwind, inline styles, CSS modules, even SVGs) is pixelated as-is.
The single child can be styled with Tailwind, inline styles, or CSS modules. React components work when they forward their ref to an HTML element. Unsupported backgrounds such as `url()` and `conic-gradient()` are preserved instead of being rewritten.

## Packages

Expand All @@ -35,7 +35,7 @@ Anything inside `<Pixel>` (Tailwind, inline styles, CSS modules, even SVGs) is p

- React 18+
- SSR: yes (Next.js, Remix, etc.)
- Browsers: any with `clip-path` and `drop-shadow` support (all modern browsers)
- Browsers: modern Chrome, Firefox, Safari, and Edge with `clip-path`, `filter: drop-shadow()`, and PNG data URL support

## Author

Expand Down
Binary file added apps/demo/public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions apps/demo/scripts/prerender.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';

const outputDirectory = new URL('../dist/', import.meta.url);
const serverDirectory = new URL('.ssr/', outputDirectory);
const entryFile = (await readdir(serverDirectory)).find((file) =>
/^entry-server\.(?:mjs|js)$/.test(file)
);

if (!entryFile) {
throw new Error('The demo SSR bundle could not be found.');
}

const { render } = await import(pathToFileURL(new URL(entryFile, serverDirectory).pathname).href);
const htmlFile = new URL('index.html', outputDirectory);
const template = await readFile(htmlFile, 'utf8');
const appHtml = render();
const html = template.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`);

if (html === template) {
throw new Error('The demo root placeholder could not be found.');
}

await writeFile(htmlFile, html);
await rm(serverDirectory, { recursive: true, force: true });
Loading
Loading