Text Toolbar
A floating text toolbar with a faceted color orb, style toggles, size, alignment and font.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/text-toolbar.jsonDependencies
- motion
- lucide-react
Interaction
Drag on the orb to pick a color, and the text follows it. Double-click the orb to reset. Toggle bold, italic, underline, strikethrough, code, highlight, link, mention and sparkle, hold the plus and minus to change the size, and switch alignment and font. Click away to hide the toolbar and click the text to bring it back.
Props
| Prop | Type | Description |
|---|---|---|
| text | string | The line of text being styled. Defaults to "Make it feel alive." |
| defaultFormat | Partial<TextFormat> | Starting style, for example { bold: true, size: 32, hue: 210 }. |
| fonts | { label; family }[] | Fonts in the dropdown. Defaults to system Hand, Sans, Serif and Mono stacks, so pass your own to use a loaded font. |
| onChange | (format: TextFormat) => void | Called with the full format after every change. |
| className | string | Extra classes merged onto the root element. |
How to use
import TextToolbar from "@/components/ui/text-toolbar"
export function Example() {
return (
<TextToolbar
text="Make it feel alive."
defaultFormat={{ size: 28 }}
onChange={(format) => console.log(format)}
/>
)
}Source code
components/ui/text-toolbar.tsx
"use client"
import { memo, useCallback, useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion, useSpring } from "motion/react"
import {
AlignCenter,
AlignLeft,
AlignRight,
AtSign,
Bold,
ChevronDown,
Code,
Highlighter,
Italic,
Link2,
Minus,
Plus,
Sparkles,
Strikethrough,
Underline,
} from "lucide-react"
import { cn } from "@/lib/utils"
export type TextAlign = "left" | "center" | "right"
export type TextFormat = {
bold: boolean
italic: boolean
underline: boolean
strike: boolean
code: boolean
highlight: boolean
link: boolean
mention: boolean
sparkle: boolean
size: number
align: TextAlign
font: string
/** 0-359 picked on the orb, or null for the default text color. */
hue: number | null
}
type Font = { label: string; family: string }
const MONO = 'var(--font-mono, ui-monospace), ui-monospace, "SFMono-Regular", monospace'
// System stacks, so the component has no font dependency. Pass `fonts` to use your own
// (for example a handwriting font loaded with next/font).
const FONTS: Font[] = [
{
label: "Hand",
family: '"Bradley Hand", "Segoe Print", "Chalkboard SE", "Comic Sans MS", cursive',
},
{ label: "Sans", family: "var(--font-sans, ui-sans-serif), system-ui, sans-serif" },
{ label: "Serif", family: 'ui-serif, Georgia, "Times New Roman", serif' },
{ label: "Mono", family: MONO },
]
const DEFAULTS: TextFormat = {
bold: false,
italic: false,
underline: false,
strike: false,
code: false,
highlight: false,
link: false,
mention: false,
sparkle: false,
size: 22,
align: "center",
font: "Hand",
hue: null,
}
const MIN = 8
const MAX = 96
const clamp = (n: number) => Math.min(MAX, Math.max(MIN, n))
type ToggleKey =
| "bold"
| "italic"
| "underline"
| "strike"
| "code"
| "highlight"
| "link"
| "mention"
| "sparkle"
const TOGGLES: { key: ToggleKey; label: string; Icon: typeof Bold }[] = [
{ key: "bold", label: "Bold", Icon: Bold },
{ key: "italic", label: "Italic", Icon: Italic },
{ key: "underline", label: "Underline", Icon: Underline },
{ key: "strike", label: "Strikethrough", Icon: Strikethrough },
{ key: "code", label: "Code", Icon: Code },
{ key: "highlight", label: "Highlight", Icon: Highlighter },
{ key: "link", label: "Link", Icon: Link2 },
{ key: "mention", label: "Mention", Icon: AtSign },
{ key: "sparkle", label: "Sparkle", Icon: Sparkles },
]
const ALIGNS: { value: TextAlign; label: string; Icon: typeof AlignLeft }[] = [
{ value: "left", label: "Align left", Icon: AlignLeft },
{ value: "center", label: "Align center", Icon: AlignCenter },
{ value: "right", label: "Align right", Icon: AlignRight },
]
const textColor = (hue: number | null) =>
hue === null ? undefined : `hsl(${hue} 90% 52%)`
/* ------------------------------------------------------------------ */
/* The orb: a sphere made of hexagonal facets, drawn once as SVG paths */
/* ------------------------------------------------------------------ */
const R = 46 // orb radius inside the 100 x 100 viewBox
const HALF_PI = Math.PI / 2
const HEX = 0.12 // hexagon circumradius, in radians on the sphere
const GAP = 0.88 // < 1 leaves a thin seam between facets
// A point on the sphere, given as (x, y) radians from the centre, lands on the screen
// at sin(distance). That is what squeezes the facets together towards the rim.
function project(x: number, y: number): [number, number] {
const t = Math.hypot(x, y)
if (t === 0) return [50, 50]
const k = Math.sin(Math.min(t, HALF_PI)) / t
return [50 + x * k * R, 50 + y * k * R]
}
function buildFacets() {
const out: { d: string; fill: string }[] = []
const step = Math.sqrt(3) * HEX
for (let q = -18; q <= 18; q++) {
for (let r = -18; r <= 18; r++) {
const cx = step * (q + r / 2)
const cy = HEX * 1.5 * r
const t = Math.hypot(cx, cy)
if (t > HALF_PI - HEX * 0.35) continue
const d =
Array.from({ length: 6 }, (_, i) => {
const a = (Math.PI / 3) * i + Math.PI / 6
const [px, py] = project(
cx + Math.cos(a) * HEX * GAP,
cy + Math.sin(a) * HEX * GAP,
)
return `${i ? "L" : "M"}${px.toFixed(2)} ${py.toFixed(2)}`
}).join("") + "Z"
// Lit from the top left: brighter where the surface faces the light.
const s = Math.sin(t)
const nx = t ? (cx / t) * s : 0
const ny = t ? (cy / t) * s : 0
const nz = Math.cos(t)
const diffuse = Math.max(0, nx * -0.45 + ny * -0.5 + nz * 0.74)
const hue = (((360 - (Math.atan2(cy, cx) * 180) / Math.PI) % 360) + 360) % 360
out.push({
d,
fill: `hsl(${hue.toFixed(0)} 90% ${(32 + 40 * diffuse ** 0.8).toFixed(0)}%)`,
})
}
}
return out
}
const FACETS = buildFacets()
// Static, so it never re-renders while the marker moves.
const Facets = memo(function Facets() {
return (
<>
{FACETS.map((f, i) => (
<path key={i} d={f.d} fill={f.fill} />
))}
</>
)
})
function ColorOrb({
hue,
onPick,
onReset,
}: {
hue: number | null
onPick: (hue: number) => void
onReset: () => void
}) {
const tile = useRef<HTMLDivElement>(null)
const pressed = useRef(false)
const reduce = useReducedMotion()
const [radius, setRadius] = useState(0.6)
const rotX = useSpring(0, { stiffness: 220, damping: 22 })
const rotY = useSpring(0, { stiffness: 220, damping: 22 })
const read = (e: React.PointerEvent) => {
const b = tile.current!.getBoundingClientRect()
return {
x: ((e.clientX - b.left) / b.width) * 100 - 50,
y: ((e.clientY - b.top) / b.height) * 100 - 50,
}
}
const pick = (e: React.PointerEvent) => {
const { x, y } = read(e)
const deg = (Math.atan2(y, x) * 180) / Math.PI
setRadius(Math.min(Math.hypot(x, y) / R, 0.88))
onPick(Math.round((((360 - deg) % 360) + 360) % 360) % 360)
}
const angle = hue === null ? 0 : ((360 - hue) * Math.PI) / 180
const mx = 50 + Math.cos(angle) * radius * R
const my = 50 + Math.sin(angle) * radius * R
return (
<div
ref={tile}
role="slider"
tabIndex={0}
aria-label="Text color"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={hue ?? 0}
aria-valuetext={hue === null ? "Default" : `${hue} degrees`}
title="Drag to pick a color. Double-click to reset."
onPointerDown={(e) => {
pressed.current = true
e.currentTarget.setPointerCapture(e.pointerId)
pick(e)
}}
onPointerMove={(e) => {
if (pressed.current) pick(e)
if (reduce) return
const { x, y } = read(e)
rotY.set((x / 50) * 10)
rotX.set((-y / 50) * 10)
}}
onPointerUp={() => (pressed.current = false)}
onPointerCancel={() => (pressed.current = false)}
onPointerLeave={() => {
rotX.set(0)
rotY.set(0)
}}
onDoubleClick={onReset}
onKeyDown={(e) => {
const step = e.shiftKey ? 30 : 10
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault()
onPick(((hue ?? 0) + step) % 360)
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault()
onPick(((hue ?? 0) - step + 360) % 360)
} else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault()
onReset()
}
}}
className="relative aspect-square w-32 shrink-0 touch-none cursor-pointer overflow-hidden rounded-2xl bg-muted outline-none focus-visible:ring-2 focus-visible:ring-foreground/40"
style={{
backgroundImage:
"radial-gradient(color-mix(in oklab, var(--foreground) 22%, transparent) 1px, transparent 1.5px)",
backgroundSize: "8px 8px",
}}
>
<motion.div
className="absolute inset-0 p-1"
style={{ rotateX: rotX, rotateY: rotY, transformPerspective: 500 }}
>
<svg viewBox="0 0 100 100" className="size-full" aria-hidden>
<Facets />
{hue !== null && (
<circle
cx={mx}
cy={my}
r={4.5}
fill={textColor(hue)}
stroke="#fff"
strokeWidth={2}
style={{ filter: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.4))" }}
/>
)}
</svg>
{/* glossy highlight and a soft shadow towards the rim */}
<span
aria-hidden
className="pointer-events-none absolute inset-1 rounded-full"
style={{
background:
"radial-gradient(circle at 32% 26%, rgb(255 255 255 / 0.5), transparent 36%), radial-gradient(circle at 50% 50%, transparent 60%, rgb(0 0 0 / 0.28) 100%)",
}}
/>
</motion.div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Small controls */
/* ------------------------------------------------------------------ */
const chip =
"grid place-items-center rounded-xl text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
function Step({
label,
onStep,
disabled,
children,
}: {
label: string
onStep: () => void
disabled: boolean
children: React.ReactNode
}) {
const wait = useRef<ReturnType<typeof setTimeout>>(undefined)
const repeat = useRef<ReturnType<typeof setInterval>>(undefined)
const stop = useCallback(() => {
clearTimeout(wait.current)
clearInterval(repeat.current)
}, [])
useEffect(() => stop, [stop])
return (
<button
type="button"
aria-label={label}
disabled={disabled}
// Hold to keep stepping. Keyboard users get one step per key press.
onPointerDown={() => {
onStep()
wait.current = setTimeout(() => (repeat.current = setInterval(onStep, 60)), 350)
}}
onPointerUp={stop}
onPointerLeave={stop}
onPointerCancel={stop}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onStep()
}
}}
className={cn(chip, "size-9")}
>
{children}
</button>
)
}
function FontSelect({
value,
fonts,
onPick,
}: {
value: string
fonts: Font[]
onPick: (label: string) => void
}) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
const current = fonts.find((f) => f.label === value) ?? fonts[0]
useEffect(() => {
if (!open) return
// Capture phase, because the toolbar stops pointer events from bubbling.
const away = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const esc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation()
setOpen(false)
}
}
document.addEventListener("pointerdown", away, true)
document.addEventListener("keydown", esc, true)
return () => {
document.removeEventListener("pointerdown", away, true)
document.removeEventListener("keydown", esc, true)
}
}, [open])
return (
<div ref={box} className="relative min-w-0 flex-1">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`Font: ${current.label}`}
onClick={() => setOpen((o) => !o)}
className="flex h-11 w-full items-center justify-center gap-1.5 rounded-2xl bg-muted px-3 text-sm font-medium"
>
<span className="truncate" style={{ fontFamily: current.family }}>
{current.label}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</button>
<AnimatePresence>
{open && (
<motion.ul
role="listbox"
aria-label="Font"
initial={{ opacity: 0, y: 6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.98 }}
transition={{ duration: 0.14 }}
className="absolute bottom-full right-0 z-10 mb-2 w-full min-w-32 rounded-2xl border border-border bg-card p-1 shadow-lg"
>
{fonts.map((f) => (
<li key={f.label} role="presentation">
<button
type="button"
role="option"
aria-selected={f.label === current.label}
onClick={() => {
onPick(f.label)
setOpen(false)
}}
style={{ fontFamily: f.family }}
className={cn(
"w-full rounded-xl px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted",
f.label === current.label && "bg-muted font-medium",
)}
>
{f.label}
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
)
}
/* ------------------------------------------------------------------ */
/* The toolbar */
/* ------------------------------------------------------------------ */
function Toolbar({
fmt,
fonts,
set,
step,
}: {
fmt: TextFormat
fonts: Font[]
set: (patch: Partial<TextFormat>) => void
step: (delta: number) => void
}) {
const id = useId()
return (
<motion.div
role="toolbar"
aria-label="Text formatting"
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
onPointerDown={(e) => e.stopPropagation()} // keep the text selected while using the toolbar
className="w-[22rem] max-w-full space-y-2 rounded-3xl border border-border bg-card p-2 shadow-xl"
>
<div className="flex gap-2">
<ColorOrb
hue={fmt.hue}
onPick={(hue) => set({ hue })}
onReset={() => set({ hue: null })}
/>
<div className="grid min-w-0 flex-1 grid-cols-3 grid-rows-3 gap-0.5 rounded-2xl bg-muted p-1">
{TOGGLES.map(({ key, label, Icon }) => (
<motion.button
key={key}
type="button"
aria-pressed={fmt[key]}
aria-label={label}
title={label}
whileTap={{ scale: 0.9 }}
onClick={() => set({ [key]: !fmt[key] })}
className={cn(
chip,
"aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm",
)}
>
<Icon className="size-4" />
</motion.button>
))}
</div>
</div>
<div className="flex gap-2">
<div className="flex h-11 items-center rounded-2xl bg-muted px-0.5">
<Step label="Decrease size" disabled={fmt.size <= MIN} onStep={() => step(-1)}>
<Minus className="size-3.5" />
</Step>
<span
aria-live="polite"
className="w-7 text-center text-sm font-medium tabular-nums"
>
{fmt.size}
</span>
<Step label="Increase size" disabled={fmt.size >= MAX} onStep={() => step(1)}>
<Plus className="size-3.5" />
</Step>
</div>
<div
role="radiogroup"
aria-label="Alignment"
className="flex h-11 items-center rounded-2xl bg-muted p-1"
>
{ALIGNS.map(({ value, label, Icon }) => {
const on = fmt.align === value
return (
<button
key={value}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
title={label}
onClick={() => set({ align: value })}
className={cn(
"relative grid size-9 place-items-center rounded-xl transition-colors",
on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{on && (
<motion.span
layoutId={`${id}-align`}
transition={{ type: "spring", stiffness: 500, damping: 36 }}
className="absolute inset-0 rounded-xl bg-background shadow-sm"
/>
)}
<Icon className="relative size-4" />
</button>
)
})}
</div>
<FontSelect value={fmt.font} fonts={fonts} onPick={(font) => set({ font })} />
</div>
</motion.div>
)
}
/* ------------------------------------------------------------------ */
/* The component */
/* ------------------------------------------------------------------ */
const SPARKS = [
{ left: "-3%", top: "-34%", size: "size-3.5" },
{ left: "97%", top: "-22%", size: "size-4" },
{ left: "90%", top: "88%", size: "size-3" },
{ left: "3%", top: "92%", size: "size-3" },
]
export default function TextToolbar({
children,
text = "Make it feel alive.",
defaultFormat,
fonts = FONTS,
onChange,
className,
}: {
/** The text to style: <TextToolbar>Hello</TextToolbar> */
children?: React.ReactNode
/** Same thing as a prop. Used only when there are no children. */
text?: string
defaultFormat?: Partial<TextFormat>
fonts?: Font[]
onChange?: (format: TextFormat) => void
className?: string
}) {
const reduce = useReducedMotion()
const [fmt, setFmt] = useState<TextFormat>(() => ({
...DEFAULTS,
font: fonts[0]?.label ?? DEFAULTS.font,
...defaultFormat,
}))
const [selected, setSelected] = useState(true)
// The ref is the source of truth, so press-and-hold stepping never reads a stale size.
const latest = useRef(fmt)
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
})
const set = useCallback((patch: Partial<TextFormat>) => {
latest.current = { ...latest.current, ...patch }
setFmt(latest.current)
onChangeRef.current?.(latest.current)
}, [])
const step = useCallback(
(delta: number) => set({ size: clamp(latest.current.size + delta) }),
[set],
)
const family = fmt.code
? MONO
: (fonts.find((f) => f.label === fmt.font) ?? fonts[0] ?? FONTS[0]).family
const decoration = [
fmt.underline || fmt.link ? "underline" : "",
fmt.strike ? "line-through" : "",
]
.filter(Boolean)
.join(" ")
return (
<div
className={cn("relative flex w-full flex-col items-center", className)}
onPointerDown={() => setSelected(false)}
onKeyDown={(e) => e.key === "Escape" && setSelected(false)}
>
{/* Reserved height, so the text doesn't jump when the toolbar hides. */}
<div className="flex h-[12.75rem] w-full items-end justify-center pb-3">
<AnimatePresence>
{selected && (
<Toolbar key="toolbar" fmt={fmt} fonts={fonts} set={set} step={step} />
)}
</AnimatePresence>
</div>
<div className="w-full max-w-md px-4" style={{ textAlign: fmt.align }}>
<span
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label="Selected text. Press to show the toolbar."
onPointerDown={(e) => {
e.stopPropagation()
setSelected(true)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setSelected(true)
}
}}
className="relative inline-block max-w-full cursor-default rounded-sm px-2.5 py-1 outline-none"
>
<AnimatePresence>
{selected && (
<>
<motion.span
key="selection"
aria-hidden
className="absolute inset-0 rounded-sm bg-foreground/10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
<motion.span
key="handle-start"
aria-hidden
className="pointer-events-none absolute -bottom-1 -left-0.5 -top-1 w-0.5 rounded-full bg-foreground"
initial={{ opacity: 0, scaleY: 0.5 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0 }}
>
<span className="absolute -left-[3px] -top-1.5 size-2 rounded-full bg-foreground" />
</motion.span>
<motion.span
key="handle-end"
aria-hidden
className="pointer-events-none absolute -bottom-1 -right-0.5 -top-1 w-0.5 rounded-full bg-foreground"
initial={{ opacity: 0, scaleY: 0.5 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0 }}
>
<span className="absolute -bottom-1.5 -right-[3px] size-2 rounded-full bg-foreground" />
</motion.span>
</>
)}
</AnimatePresence>
<span
className={cn(
"relative whitespace-pre-wrap break-words transition-[font-size] duration-200",
fmt.code && "rounded-md bg-muted px-1.5 py-0.5",
fmt.mention && "rounded-md bg-sky-500/15 px-1.5",
fmt.highlight && "rounded-sm bg-yellow-300/60 px-0.5 dark:bg-yellow-400/40",
)}
style={{
fontFamily: family,
fontSize: fmt.size,
fontWeight: fmt.bold ? 700 : 400,
fontStyle: fmt.italic ? "italic" : "normal",
textDecoration: decoration || "none",
textDecorationColor: fmt.link ? "#3b82f6" : undefined,
textDecorationThickness: fmt.link ? 2 : undefined,
textUnderlineOffset: 4,
color: textColor(fmt.hue),
lineHeight: 1.3,
}}
>
{fmt.mention && "@"}
{children ?? text}
</span>
{fmt.sparkle &&
SPARKS.map((s, i) => (
<motion.span
key={i}
aria-hidden
className="pointer-events-none absolute text-amber-400"
style={{ left: s.left, top: s.top }}
animate={
reduce
? undefined
: {
scale: [0.6, 1.15, 0.6],
opacity: [0.35, 1, 0.35],
rotate: [0, 18, 0],
}
}
transition={{
duration: 1.8,
repeat: Infinity,
delay: i * 0.4,
ease: "easeInOut",
}}
>
<Sparkles className={s.size} />
</motion.span>
))}
</span>
</div>
<p className="mt-5 text-xs text-muted-foreground" aria-live="polite">
{selected
? "Drag on the orb to pick a color."
: "Click the text to bring the toolbar back."}
</p>
</div>
)
}