reka-ui
Version:
Vue port for Radix UI Primitives.
348 lines (305 loc) • 13.2 kB
text/typescript
import type { Color, ColorChannel, ColorSpace, RGBColor } from './types'
import { colorToString, convertToHsb, convertToHsl, convertToRgb } from './convert'
/**
* Generates a CSS gradient for a color slider track.
*/
export function getSliderGradient(
color: Color,
channel: ColorChannel,
colorSpace: ColorSpace = color.space as ColorSpace,
): string {
const hsl = convertToHsl(color)
const hsb = convertToHsb(color)
switch (channel) {
case 'hue':
return getHueGradient()
case 'saturation':
return getSaturationGradient(hsl, colorSpace)
case 'lightness':
return getLightnessGradient(hsl)
case 'brightness':
return getBrightnessGradient(hsb)
case 'red':
return getRedGradient(color)
case 'green':
return getGreenGradient(color)
case 'blue':
return getBlueGradient(color)
case 'alpha':
return getAlphaGradient(color)
default:
return ''
}
}
/**
* Generates a CSS gradient for a color area (2D picker).
*/
export function getAreaGradient(
color: Color,
xChannel: ColorChannel,
yChannel: ColorChannel,
colorSpace: ColorSpace = color.space as ColorSpace,
): { background: string, gradientX: string, gradientY: string } {
const hsl = convertToHsl(color)
const hsb = convertToHsb(color)
// Determine which gradient layers to apply based on channels
const gradientX = getChannelGradientForArea(color, xChannel, colorSpace, 'x')
const gradientY = getChannelGradientForArea(color, yChannel, colorSpace, 'y')
// Background is the color with both channels at max
const bgColor = getAreaBackgroundColor(color, xChannel, yChannel, colorSpace)
return {
background: bgColor,
gradientX,
gradientY,
}
}
function getHueGradient(): string {
return 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)'
}
function getSaturationGradient(hsl: { h: number, s: number, l: number, alpha: number }, colorSpace: ColorSpace): string {
const start = colorToString({ space: 'hsl', h: hsl.h, s: 0, l: colorSpace === 'hsl' ? hsl.l : 50, alpha: 1 }, 'hsl')
const end = colorToString({ space: 'hsl', h: hsl.h, s: 100, l: colorSpace === 'hsl' ? hsl.l : 50, alpha: 1 }, 'hsl')
return `linear-gradient(to right, ${start}, ${end})`
}
function getLightnessGradient(hsl: { h: number, s: number, l: number, alpha: number }): string {
const start = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 0, alpha: 1 }, 'hsl')
const mid = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 50, alpha: 1 }, 'hsl')
const end = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 100, alpha: 1 }, 'hsl')
return `linear-gradient(to right, ${start}, ${mid}, ${end})`
}
function getBrightnessGradient(hsb: { h: number, s: number, b: number, alpha: number }): string {
const start = colorToString({ space: 'hsb', h: hsb.h, s: hsb.s, b: 0, alpha: 1 }, 'rgb')
const end = colorToString({ space: 'hsb', h: hsb.h, s: hsb.s, b: 100, alpha: 1 }, 'rgb')
return `linear-gradient(to right, ${start}, ${end})`
}
function getRedGradient(color: Color): string {
const { g, b, alpha } = color.space === 'rgb' ? color : { g: 128, b: 128, alpha: 1 }
const start = colorToString({ space: 'rgb', r: 0, g, b, alpha: 1 }, 'rgb')
const end = colorToString({ space: 'rgb', r: 255, g, b, alpha: 1 }, 'rgb')
return `linear-gradient(to right, ${start}, ${end})`
}
function getGreenGradient(color: Color): string {
const { r, b, alpha } = color.space === 'rgb' ? color : { r: 128, b: 128, alpha: 1 }
const start = colorToString({ space: 'rgb', r, g: 0, b, alpha: 1 }, 'rgb')
const end = colorToString({ space: 'rgb', r, g: 255, b, alpha: 1 }, 'rgb')
return `linear-gradient(to right, ${start}, ${end})`
}
function getBlueGradient(color: Color): string {
const { r, g, alpha } = color.space === 'rgb' ? color : { r: 128, g: 128, alpha: 1 }
const start = colorToString({ space: 'rgb', r, g, b: 0, alpha: 1 }, 'rgb')
const end = colorToString({ space: 'rgb', r, g, b: 255, alpha: 1 }, 'rgb')
return `linear-gradient(to right, ${start}, ${end})`
}
// Checkerboard pattern used behind alpha gradients to visualize transparency
const CHECKERBOARD_LAYERS = [
'linear-gradient(45deg, #ccc 25%, transparent 25%)',
'linear-gradient(-45deg, #ccc 25%, transparent 25%)',
'linear-gradient(45deg, transparent 75%, #ccc 75%)',
'linear-gradient(-45deg, transparent 75%, #ccc 75%)',
].join(', ')
function getAlphaGradient(color: Color): string {
const { r, g, b } = color.space === 'rgb' ? color : convertToRgb(color)
const solidRgb = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`
return `linear-gradient(to right, transparent, ${solidRgb}), ${CHECKERBOARD_LAYERS}`
}
function getChannelGradientForArea(
color: Color,
channel: ColorChannel,
colorSpace: ColorSpace,
axis: 'x' | 'y',
): string {
const direction = axis === 'x' ? 'to right' : 'to top'
const hsl = convertToHsl(color)
const hsb = convertToHsb(color)
switch (channel) {
case 'saturation': {
if (colorSpace === 'hsb') {
// For HSB: White to transparent (overlay on pure hue)
// Left side (0% sat) = white, Right side (100% sat) = transparent (shows pure hue)
return `linear-gradient(${direction}, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))`
}
// For HSL: White to full color
const fullColor = colorToString({ space: 'hsl', h: hsl.h, s: 100, l: 50, alpha: 1 }, 'rgb')
return `linear-gradient(${direction}, #ffffff, ${fullColor})`
}
case 'lightness': {
// White -> color -> black
const mid = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 50, alpha: 1 }, 'rgb')
return `linear-gradient(${direction}, #000000, ${mid}, #ffffff)`
}
case 'brightness': {
// For HSB: Transparent to black (overlay on pure hue)
// Top (100% brightness) = transparent (shows pure hue), Bottom (0% brightness) = black
return `linear-gradient(${direction}, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1))`
}
default:
return ''
}
}
function getAreaBackgroundColor(
color: Color,
xChannel: ColorChannel,
yChannel: ColorChannel,
colorSpace: ColorSpace,
): string {
const hsl = convertToHsl(color)
const hsb = convertToHsb(color)
// For HSL saturation/lightness area, show pure hue
if (colorSpace === 'hsl' && xChannel === 'saturation' && yChannel === 'lightness') {
return colorToString({ space: 'hsl', h: hsl.h, s: 100, l: 50, alpha: 1 }, 'rgb')
}
// For HSB saturation/brightness area, show pure hue
if (colorSpace === 'hsb' && xChannel === 'saturation' && yChannel === 'brightness') {
return colorToString({ space: 'hsb', h: hsb.h, s: 100, b: 100, alpha: 1 }, 'rgb')
}
// Default to white
return '#ffffff'
}
/**
* Gets the CSS background style for a color area.
*/
export function getAreaBackgroundStyle(
color: Color,
xChannel: ColorChannel,
yChannel: ColorChannel,
colorSpace: ColorSpace = color.space as ColorSpace,
): Record<string, string> {
const hsl = convertToHsl(color)
const hsb = convertToHsb(color)
// Hue-based color picker (Hue/Saturation, Hue/Lightness, Hue/Brightness)
// Shows full rainbow spectrum horizontally
if (xChannel === 'hue') {
// Full hue gradient as background (rainbow spectrum)
const hueGradient = 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)'
if (yChannel === 'saturation') {
// Vertical: gray at bottom (0% sat) fading to transparent at top (100% sat shows hue)
// No blend mode needed — transparent stacking is correct
const desatColor = colorToString({ space: 'hsl', h: hsl.h, s: 0, l: hsl.l, alpha: 1 }, 'rgb')
return {
backgroundImage: `linear-gradient(to bottom, transparent, ${desatColor}), ${hueGradient}`,
}
}
if (yChannel === 'lightness') {
// Vertical: black at bottom (L=0) → transparent at middle (L=50, shows hue) → white at top (L=100)
return {
backgroundImage: `linear-gradient(to top, #000000, transparent, #ffffff), ${hueGradient}`,
}
}
if (yChannel === 'brightness') {
// Vertical: black at bottom (B=0) → transparent at top (B=100, shows hue)
return {
backgroundImage: `linear-gradient(to top, #000000, transparent), ${hueGradient}`,
}
}
}
// Classic color picker gradients for saturation-based pickers
if (xChannel === 'saturation' && (yChannel === 'lightness' || yChannel === 'brightness')) {
if (colorSpace === 'hsl') {
// HSL: Base is pure hue at 50% lightness
// Saturation x-axis: gray (S=0) at left fading to transparent (shows pure hue at S=100)
// Lightness y-axis: black at bottom (L=0), transparent at middle (L=50), white at top (L=100)
const hueColor = colorToString({ space: 'hsl', h: hsl.h, s: 100, l: 50, alpha: 1 }, 'rgb')
const grayColor = colorToString({ space: 'hsl', h: hsl.h, s: 0, l: 50, alpha: 1 }, 'rgb')
const satGradient = `linear-gradient(to right, ${grayColor}, transparent)`
const lightGradient = `linear-gradient(to top, #000000, transparent, #ffffff)`
return {
backgroundColor: hueColor,
backgroundImage: `${lightGradient}, ${satGradient}`,
}
}
if (colorSpace === 'hsb') {
// HSB: Base is pure hue (full sat, full brightness)
// Top edge: white (0% sat) → pure hue (100% sat)
// Bottom edge: always black (0% brightness)
const hueColor = colorToString({ space: 'hsb', h: hsb.h, s: 100, b: 100, alpha: 1 }, 'rgb')
// White to transparent (left to right)
const satGradient = `linear-gradient(to right, #ffffff, transparent)`
// Black gradient from bottom up
const brightGradient = `linear-gradient(to top, #000000, transparent)`
return {
backgroundColor: hueColor,
backgroundImage: `${brightGradient}, ${satGradient}`,
}
}
}
// RGB color picker (Red/Green, Red/Blue, Green/Blue)
// Uses screen blend mode to combine gradients additively (like React Spectrum)
// Formula: 1 - (1 - a) * (1 - b), effectively adds RGB values
if (colorSpace === 'rgb'
&& (xChannel === 'red' || xChannel === 'green' || xChannel === 'blue')
&& (yChannel === 'red' || yChannel === 'green' || yChannel === 'blue')) {
const rgb = convertToRgb(color)
// Get the constant channel (z-channel - the one NOT x or y)
const allChannels = ['red', 'green', 'blue'] as const
const varyingChannels = [xChannel, yChannel]
const constantChannel = allChannels.find(c => !varyingChannels.includes(c))!
const constantValue = rgb[constantChannel === 'red' ? 'r' : constantChannel === 'green' ? 'g' : 'b']
// Create the three layers for screen blend mode:
// 1. X gradient: black to full-X-color
// 2. Y gradient: black to full-Y-color
// 3. Background: black with Z channel set
// X gradient: black (0,0,0) → full X (255,0,0) for red, etc.
const xColorStart: RGBColor = { space: 'rgb', r: 0, g: 0, b: 0, alpha: 1 }
const xColorEnd: RGBColor = {
space: 'rgb',
r: xChannel === 'red' ? 255 : 0,
g: xChannel === 'green' ? 255 : 0,
b: xChannel === 'blue' ? 255 : 0,
alpha: 1,
}
const xGradient = `linear-gradient(to right, ${colorToString(xColorStart, 'rgb')}, ${colorToString(xColorEnd, 'rgb')})`
// Y gradient: black (0,0,0) → full Y (0,255,0) for green, etc.
const yColorEnd: RGBColor = {
space: 'rgb',
r: yChannel === 'red' ? 255 : 0,
g: yChannel === 'green' ? 255 : 0,
b: yChannel === 'blue' ? 255 : 0,
alpha: 1,
}
const yGradient = `linear-gradient(to top, ${colorToString(xColorStart, 'rgb')}, ${colorToString(yColorEnd, 'rgb')})`
// Background: black with constant Z channel value
const bgColor: RGBColor = {
space: 'rgb',
r: constantChannel === 'red' ? constantValue : 0,
g: constantChannel === 'green' ? constantValue : 0,
b: constantChannel === 'blue' ? constantValue : 0,
alpha: 1,
}
return {
backgroundColor: colorToString(bgColor, 'rgb'),
backgroundImage: `${yGradient}, ${xGradient}`,
backgroundBlendMode: 'screen',
}
}
// Fallback for other combinations
const { background, gradientX, gradientY } = getAreaGradient(color, xChannel, yChannel, colorSpace)
const gradients: string[] = []
if (gradientY)
gradients.push(gradientY)
if (gradientX)
gradients.push(gradientX)
return {
backgroundColor: background,
backgroundImage: gradients.join(', '),
}
}
/**
* Gets the CSS background for a slider track.
*/
export function getSliderBackgroundStyle(
color: Color,
channel: ColorChannel,
colorSpace: ColorSpace = color.space as ColorSpace,
): Record<string, string> {
const gradient = getSliderGradient(color, channel, colorSpace)
if (channel === 'alpha') {
return {
background: gradient,
backgroundSize: '100% 100%, 8px 8px, 8px 8px, 8px 8px, 8px 8px',
backgroundPosition: '0 0, 0 0, 0 4px, 4px -4px, -4px 0px',
}
}
return {
background: gradient,
}
}