Add Tile
The dashed add-one tile at the end of a row or grid of thumbnails: a plus above a label, and on hover the edge, icon and label turn accent with no fill.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/add-tile.jsonUsage
import { AddTile } from "@/components/ui/add-tile"Examples
Default
Fills its box as a square. Give it the size of the thumbnails beside it.
Menu trigger
It is a plain button, so it works as the trigger of a menu. Change the icon and label to say what it adds.
Drop target
dragOver draws the hover look while a file is held over the tile. You own the drag events.
Loading and disabled
Loading swaps the icon for a spinner and ignores clicks, but stays in the tab order.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| label | React.ReactNode | - | The text under the icon. Default Add. For an icon-only tile pass aria-label too. |
| icon | IconProp | - | Shown above the label. Default is a plus. |
| loading | boolean | - | Swaps the icon for a spinner and ignores clicks. |
| dragOver | boolean | - | Draws the hover look while a file is dragged over the tile, and sets data-drag-over. |
| ...props | React.ComponentProps<"button"> | - | Every other button prop and the ref are forwarded. Size it with className (size-24). |
labelReact.ReactNodeThe text under the icon. Default Add. For an icon-only tile pass aria-label too. |
| iconIconPropShown above the label. Default is a plus. |
| loadingbooleanSwaps the icon for a spinner and ignores clicks. |
dragOverbooleanDraws the hover look while a file is dragged over the tile, and sets data-drag-over. |
...propsReact.ComponentProps<"button">Every other button prop and the ref are forwarded. Size it with className (size-24). |
"use client"
import * as React from "react"
import { IconLoader2 as Loader2, IconPlus as Plus } from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
import { renderIconProp, type IconProp } from "@/registry/lib/icon-prop"
type AddTileProps = Omit<React.ComponentProps<"button">, "children"> & {
/** The text under the icon. Default "Add". */
label?: React.ReactNode
/** Shown above the label. An element (`<IconPhotoPlus />`), like every other
* icon prop in the kit. Default is a plus. */
icon?: IconProp
/** Swaps the icon for a spinner and ignores clicks, without leaving the tab
* order, so a menu or a picker does not lose focus mid-wait. */
loading?: boolean
/** Draws the hover look while a file is dragged over the tile. You own the
* drag events; this only paints the answer. Also sets `data-drag-over`. */
dragOver?: boolean
}
// The hover look is NO fill: the dashed edge, the icon and the label all move
// to the accent and nothing else changes. Ghost Button paints an accent wash on
// hover, which on a bare dashed outline reads as a button and not as an empty
// slot. Dark mode takes accent-400 for the text and the edge, the step the kit
// uses for accent text on a dark page (Button `soft`): accent-500 is a fill
// step and is too dim to read as text against it.
//
// Hover only applies to an enabled, idle tile, so a busy or disabled one never
// advertises a click it will ignore.
const accentLook = "border-accent-500 text-accent-500 dark:border-accent-400 dark:text-accent-400"
const accentHover =
"hover:border-accent-500 hover:text-accent-500 dark:hover:border-accent-400 dark:hover:text-accent-400 focus-visible:border-accent-500 focus-visible:text-accent-500 dark:focus-visible:border-accent-400 dark:focus-visible:text-accent-400"
const base =
"group/add-tile relative inline-flex aspect-square w-full shrink-0 select-none flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-border bg-transparent text-xs font-medium text-muted-foreground cursor-pointer outline-none transition-[color,border-color] duration-small ease-small motion-reduce:transition-none focus-visible:ring-ring-focus focus-visible:ring-[3px] disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:[stroke-width:var(--stroke-icon)]"
/**
* The dashed "add something here" tile at the end of a row or grid of
* thumbnails: a media strip, a photo picker. It is a plain `button`, so it
* opens a file picker from `onClick`, or serves as the trigger of a menu
* (`<DropdownMenuTrigger render={<AddTile />} />`). It fills its box as a
* square; give it `className="size-24"` to match fixed thumbnails.
*/
// @use-when the dashed "add one" tile at the end of a row or grid of thumbnails (a media strip, a photo picker). Not Button, which is an action with a label in a row of controls.
function AddTile({
label = "Add",
icon,
loading = false,
dragOver = false,
disabled,
className,
onClick,
type = "button",
...props
}: AddTileProps) {
const idle = !disabled && !loading
return (
<button
type={type}
data-slot="add-tile"
data-drag-over={dragOver ? "" : undefined}
data-loading={loading ? "" : undefined}
aria-busy={loading || undefined}
disabled={disabled}
onClick={(e) => {
if (loading) {
e.preventDefault()
return
}
onClick?.(e)
}}
{...props}
className={cn(
base,
idle && accentHover,
idle && dragOver && accentLook,
loading && "cursor-wait",
className
)}
>
{loading ? (
// motion-exempt: an indeterminate spinner is the only "still working"
// cue, so it keeps spinning under reduced motion. Stopping it reads as
// frozen rather than as calm.
<Loader2 className="animate-spin" aria-hidden />
) : (
icon === undefined ? <Plus aria-hidden /> : renderIconProp(icon, { "aria-hidden": true })
)}
{label}
</button>
)
}
export { AddTile }
export type { AddTileProps }