Transform Plus Frame
Free Frames React component — TransformPlusFrame. MIT licensed, copy-paste ready for Next.js and Tailwind CSS.
Bounding box with plus corner handles — thicker handle strokes than border lines. Defaults to all black; pass lineColor and handleColor for dual-tone frames.
Preview
Setup
How to use
Free for personal and commercial use. No UI attribution required. Include the MIT copyright notice when copying component source into your project. Read the MIT license.
- 1
Run in your terminal:
$npm install clsx tailwind-merge lucide-react - 2
Copy
lib/cn.tsbelow. Skip if you already havecn(). - 3
Copy the code below and create
components/frames/transform-plus-frame.tsxin your project. - 4
Import and render:
Exampleimport { TransformPlusFrame } from "@/components/frames/transform-plus-frame";
lib/cn.ts
import { type ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";export function cn(...inputs: ClassValue[]) {return twMerge(clsx(inputs));}
components/frames/transform-plus-frame.tsx
import {forwardRef,type ComponentPropsWithoutRef,type CSSProperties,type ReactNode,} from "react";import { cn } from "@/lib/cn";const DEFAULT_LINE_COLOR = "#171717";const DEFAULT_HANDLE_COLOR = "#171717";const PLUS_STROKE = 2;function resolveFrameColors({color,lineColor,handleColor,}: Readonly<{color?: string;lineColor?: string;handleColor?: string;}>) {const shared = color ?? DEFAULT_LINE_COLOR;return {line: lineColor ?? shared,handle: handleColor ?? color ?? DEFAULT_HANDLE_COLOR,};}const MEDIA_SLOT ="[&_img]:block [&_img]:size-full [&_img]:object-cover [&_video]:block [&_video]:size-full [&_video]:object-cover [&_iframe]:block [&_iframe]:size-full [&_iframe]:border-0";const FRAME_INSET = "top-2 right-2 bottom-2 left-2";const FRAME_EDGE_X = "inset-x-0";const FRAME_EDGE_Y = "inset-y-0";function toCssSize(value: string | number) {return typeof value === "number" ? `${value}px` : value;}function MediaSkeleton() {return (<div className="absolute inset-0 overflow-hidden bg-neutral-100"><div className="absolute inset-0 bg-neutral-200/45 motion-safe:animate-pulse" /><div className="absolute inset-0 flex items-center justify-center"><svgviewBox="0 0 48 48"aria-hiddenclassName="size-14 text-neutral-300"fill="none"stroke="currentColor"strokeWidth="1.5"><rect x="4" y="8" width="40" height="32" rx="2" /><circle cx="16" cy="18" r="3" fill="currentColor" stroke="none" /><pathd="M8 34l10-9 7 6 6-5 9 8"strokeLinecap="round"strokeLinejoin="round"/></svg></div></div>);}function MediaIcon({children,label,}: Readonly<{ children: ReactNode; label: string }>) {return (<div className="flex flex-col items-center gap-1.5"><div className="flex size-9 items-center justify-center rounded-md border border-neutral-200 bg-white text-neutral-500">{children}</div><span className="font-mono text-[9px] text-neutral-400">{label}</span></div>);}function MediaPlaceholder() {return (<div className="flex size-full flex-col items-center justify-center gap-3 bg-neutral-50 p-4"><div className="flex items-center gap-4"><MediaIcon label="image"><svgviewBox="0 0 20 20"aria-hiddenclassName="size-4"fill="none"stroke="currentColor"strokeWidth="1.5"><rect x="2.5" y="4" width="15" height="12" rx="1.5" /><circlecx="7"cy="8.5"r="1.25"fill="currentColor"stroke="none"/><pathd="M5.5 14l3.5-3 2.5 2 2-1.5 3 2.5"strokeLinecap="round"strokeLinejoin="round"/></svg></MediaIcon><MediaIcon label="video"><svgviewBox="0 0 20 20"aria-hiddenclassName="size-4"fill="none"stroke="currentColor"strokeWidth="1.5"><rect x="2.5" y="5" width="10" height="10" rx="1.5" /><path d="M12.5 8.5l4.5-2.5v8l-4.5-2.5z" strokeLinejoin="round" /></svg></MediaIcon><MediaIcon label="media"><svgviewBox="0 0 20 20"aria-hiddenclassName="size-4"fill="none"stroke="currentColor"strokeWidth="1.5"><rect x="3" y="3" width="14" height="14" rx="2" /><path d="M7 10h6M10 7v6" strokeLinecap="round" /></svg></MediaIcon></div><p className="font-sans text-[11px] text-neutral-500">Pass image, video, or media as children</p></div>);}type CornerPlusProps = Readonly<{handleColor: string;className?: string;}>;function CornerPlus({ className, handleColor }: CornerPlusProps) {return (<spanaria-hiddenclassName={cn("pointer-events-none absolute z-20 size-4 -translate-x-1/2 -translate-y-1/2",className,)}><svgviewBox="0 0 16 16"className="size-full overflow-visible"fill="none"shapeRendering="crispEdges"><pathd="M8 0v16M0 8h16"stroke={handleColor}strokeLinecap="butt"strokeWidth={PLUS_STROKE}/></svg></span>);}type FrameEdgeProps = Readonly<{lineColor: string;className?: string;}>;function FrameEdge({ className, lineColor }: FrameEdgeProps) {return (<spanaria-hiddenstyle={{ borderColor: lineColor }}className={cn("pointer-events-none absolute z-10", className)}/>);}export type TransformPlusFrameProps = Readonly<{children?: ReactNode;width?: string | number;height?: string | number;color?: string;lineColor?: string;handleColor?: string;mediaClassName?: string;label?: string;skeleton?: boolean;} & ComponentPropsWithoutRef<"div">>;export const TransformPlusFrame = forwardRef<HTMLDivElement,TransformPlusFrameProps>(({className,children,width,height,color,lineColor,handleColor,mediaClassName,label,skeleton = false,style,...props},ref,) => {const hasFixedHeight = height !== undefined;const colors = resolveFrameColors({ color, lineColor, handleColor });const frameStyle: CSSProperties = {...(width !== undefined ? { width: toCssSize(width) } : undefined),...(height !== undefined ? { height: toCssSize(height) } : undefined),...style,};return (<divref={ref}data-slot="transform-plus-frame"className={cn("font-sans",width === undefined && "w-full max-w-xl",className,)}style={frameStyle}{...props}><div className={cn("relative p-2", hasFixedHeight && "h-full")}><divclassName={cn("relative z-0 min-h-0 overflow-hidden bg-neutral-50",hasFixedHeight ? "h-full" : (mediaClassName ?? "aspect-video"),)}><div className={cn("absolute inset-0", MEDIA_SLOT)}>{skeleton ? (<MediaSkeleton />) : ((children ?? <MediaPlaceholder />))}</div></div><FrameEdgelineColor={colors.line}className={cn(FRAME_INSET, FRAME_EDGE_X, "border-t")}/><FrameEdgelineColor={colors.line}className={cn(FRAME_INSET, FRAME_EDGE_Y, "border-r")}/><FrameEdgelineColor={colors.line}className={cn(FRAME_INSET, FRAME_EDGE_X, "border-b")}/><FrameEdgelineColor={colors.line}className={cn(FRAME_INSET, FRAME_EDGE_Y, "border-l")}/><CornerPlus handleColor={colors.handle} className="top-2 left-2" /><CornerPlushandleColor={colors.handle}className="top-2 right-2 translate-x-1/2"/><CornerPlushandleColor={colors.handle}className="bottom-2 left-2 translate-y-1/2"/><CornerPlushandleColor={colors.handle}className="right-2 bottom-2 translate-x-1/2 translate-y-1/2"/></div>{label ? (<p className="mt-2 text-center font-mono text-[10px] tracking-[0.16em] text-neutral-400 uppercase">{label}</p>) : null}</div>);},);TransformPlusFrame.displayName = "TransformPlusFrame";