Skip to content

feat(checkbox): add state-specific class overrides and renderIcon + docs #413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
137 changes: 123 additions & 14 deletions apps/docs/src/content/docs/components/checkbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ import { LinkButton } from '@/components/react/LinkButton';
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
import importedCode from '@rnr/reusables/components/ui/checkbox?raw';

<LinkButton href="https://rn-primitives.vercel.app/checkbox">
Checkbox Primitive
</LinkButton>
<LinkButton target="_blank" href="https://rnr-showcase.vercel.app/checkbox">
<LinkButton href='https://rn-primitives.vercel.app/checkbox'>Checkbox Primitive</LinkButton>
<LinkButton target='_blank' href='https://rnr-showcase.vercel.app/checkbox'>
Demo
</LinkButton>

Expand All @@ -23,6 +21,7 @@ import importedCode from '@rnr/reusables/components/ui/checkbox?raw';
A box that is a checked (ticked) indicator when activated.

### Installation

<Tabs>
<TabItem label='CLI'>
```bash
Expand All @@ -40,28 +39,138 @@ A box that is a checked (ticked) indicator when activated.


<Code code={importedCode} lang="tsx" title="~/components/ui/checkbox.tsx" />

</TabItem>
</Tabs>
### Usage

```tsx
import * as React from 'react';
import { Checkbox } from '~/components/ui/checkbox';

function Example() {
const [checked, setChecked] = React.useState(false);
return (
<Checkbox checked={checked} onCheckedChange={setChecked} />
);
const [checked, setChecked] = React.useState(false);
return <Checkbox checked={checked} onCheckedChange={setChecked} />;
}
```

## Props

### Checkbox

Extends [`Pressable`](https://reactnative.dev/docs/pressable#props) props
Extends [`Pressable`](https://reactnative.dev/docs/pressable#props) props.

| Prop | Type | Default | Notes |
| ------------------------ | ------------------------------------- | --------------------------- | ---------------------------------------------------- |
| `checked` **\*** | `boolean` | — | Controlled state. |
| `onCheckedChange` **\*** | `(checked: boolean) => void` | — | Change handler. |
| `disabled` | `boolean` | `false` | Disables interactions. |
| `className` | `string` | — | Base classes always applied (shape/size/etc.). |
| `checkedClassName` | `string` | `bg-primary border-primary` | Added **only** when checked. |
| `uncheckedClassName` | `string` | `border-primary` | Added **only** when not checked. |
| `indicatorClassName` | `string` | — | Container for the icon (fills the box). |
| `iconClassName` | `string` | — | Applied to the default icon always. |
| `iconCheckedClassName` | `string` | `text-primary-foreground` | Extra classes for the default icon when checked. |
| `iconUncheckedClassName` | `string` | — | Extra classes for the default icon when not checked. |
| `renderIcon` | `({ checked: boolean }) => ReactNode` | default check icon | Render a custom icon; overrides `icon*ClassName`. |

**Theme defaults** (when you don’t pass overrides):

- Unchecked border: `border-primary`
- Checked: `bg-primary border-primary`
- Checked icon: `text-primary-foreground`

---

## Customizing styles (no presets)

Use state-aware class props to color only when checked (and keep defaults otherwise).

```tsx
<Checkbox
checked={checked}
onCheckedChange={setChecked}
// base shape/size
className='native:rounded-none web:rounded-none'
// applied only when checked
checkedClassName='bg-emerald-600 border-emerald-600 native:bg-emerald-600 native:border-emerald-600'
// applied only when NOT checked
uncheckedClassName='border-zinc-400'
// default icon color when checked
iconCheckedClassName='text-white'
/>
```

**Web-only selectors:** you can also do:

```tsx
<Checkbox
checked={checked}
onCheckedChange={setChecked}
className='data-[state=checked]:bg-red-500 data-[state=checked]:border-red-500'
/>
```

> React Native doesn’t support `data-*` selectors—prefer `checkedClassName` on native.

---

## Custom icon with `renderIcon`

When you supply `renderIcon`, you fully control the icon node.

```tsx
import { Platform, View } from 'react-native';
import { Check } from '~/lib/icons/Check';

<Checkbox
checked={checked}
onCheckedChange={setChecked}
checkedClassName='bg-blue-600 border-blue-600 native:bg-blue-600 native:border-blue-600'
renderIcon={({ checked }) =>
checked ? (
<Check size={14} strokeWidth={Platform.OS === 'web' ? 2.5 : 3.5} className='text-white' />
) : null
}
/>;
```

Show a subtle dot when unchecked:

```tsx
renderIcon={({ checked }) =>
checked ? (
<Check size={12} className="text-white" />
) : (
<View className="h-1.5 w-1.5 rounded-[2] bg-zinc-400" />
)
}
```

| Prop | Type | Note |
| :---------------: | :------------------------: | :----------: |
| checked\* | boolean | |
| onCheckedChange\* | (checked: boolean) => void | |
| disabled | boolean | _(optional)_ |
---

## Examples

**Square, red when checked**

```tsx
<Checkbox
checked={checked}
onCheckedChange={setChecked}
className='native:rounded-none web:rounded-none'
checkedClassName='bg-red-500 border-red-500 native:bg-red-500 native:border-red-500'
iconCheckedClassName='text-white'
/>
```

**Subtle unchecked, bold checked**

```tsx
<Checkbox
checked={checked}
onCheckedChange={setChecked}
uncheckedClassName='border-zinc-400'
checkedClassName='bg-emerald-600 border-emerald-600 native:bg-emerald-600 native:border-emerald-600'
iconCheckedClassName='text-white'
/>
```
89 changes: 68 additions & 21 deletions packages/reusables/src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,77 @@ import { Platform } from 'react-native';
import { Check } from '../../lib/icons/Check';
import { cn } from '../../lib/utils';

function Checkbox({
className,
...props
}: CheckboxPrimitive.RootProps & {
ref?: React.RefObject<CheckboxPrimitive.RootRef>;
}) {
export interface CheckboxProps extends CheckboxPrimitive.RootProps {
checkedClassName?: string;
uncheckedClassName?: string;
indicatorClassName?: string;
iconClassName?: string;
iconCheckedClassName?: string;
iconUncheckedClassName?: string;
renderIcon?: (opts: { checked: boolean }) => React.ReactNode;
}

function CheckboxImpl(
{
className,
checkedClassName,
uncheckedClassName,
indicatorClassName,
iconClassName,
iconCheckedClassName,
iconUncheckedClassName,
renderIcon,
checked,
...props
}: CheckboxProps,
ref: React.ForwardedRef<CheckboxPrimitive.RootRef>
) {
const baseRoot =
'web:peer h-4 w-4 native:h-[20] native:w-[20] shrink-0 rounded-sm native:rounded ' +
'border web:ring-offset-background web:focus-visible:outline-none ' +
'web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2 ' +
'disabled:cursor-not-allowed disabled:opacity-50';

// Defaults so "just checked/onCheckedChange" works
const defaultUnchecked = 'border-primary';
const defaultChecked = ['bg-primary', 'border-primary'];
const defaultIconChecked = 'text-primary-foreground';

const rootClasses = cn(
baseRoot,
defaultUnchecked,
checked && defaultChecked,
checked ? checkedClassName : uncheckedClassName,
className
);

const iconClasses = cn(
checked && defaultIconChecked,
iconClassName,
checked ? iconCheckedClassName : iconUncheckedClassName
);

return (
<CheckboxPrimitive.Root
className={cn(
'web:peer h-4 w-4 native:h-[20] native:w-[20] shrink-0 rounded-sm native:rounded border border-primary web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.checked && 'bg-primary',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('items-center justify-center h-full w-full')}>
<Check
size={12}
strokeWidth={Platform.OS === 'web' ? 2.5 : 3.5}
className='text-primary-foreground'
/>
<CheckboxPrimitive.Root ref={ref} checked={checked} className={rootClasses} {...props}>
<CheckboxPrimitive.Indicator
className={cn('items-center justify-center h-full w-full', indicatorClassName)}
>
{renderIcon ? (
renderIcon({ checked: !!checked })
) : (
<Check
size={12}
strokeWidth={Platform.OS === 'web' ? 2.5 : 3.5}
className={iconClasses}
/>
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}

export { Checkbox };
const ForwardedCheckbox = React.forwardRef<CheckboxPrimitive.RootRef, CheckboxProps>(CheckboxImpl);
ForwardedCheckbox.displayName = 'Checkbox';
export function Checkbox(props: CheckboxProps) {
return <ForwardedCheckbox {...props} />;
}