Glow Columns
An index through the day: thin stems grow under bright caps in turn, low periods sitting as green dots.
import { useEffect } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';
import { useCountUp } from '../../lib/kinetik/hooks/use-count-up';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type GlowColumnsProps = {
/** One value per period, oldest first. */
values: number[];
/** Top of the scale. Defaults to the largest value. */
max?: number;
/** Index of the current period, which sets the big number. Defaults to 0. */
current?: number;
/** Word beside the big number, e.g. "MODERATE". */
level?: string;
/** Short name in the corner, e.g. "UVI". */
name?: string;
/** Period labels, spread under the columns. */
labels?: string[];
style?: StyleProp<ViewStyle>;
};
const CHART = 66;
const CAP = 6;
const STEM = 4;
const LOW = 'hsl(138, 76%, 48%)';
const MID = 'hsl(52, 94%, 58%)';
const HIGH = 'hsl(28, 96%, 56%)';
/** The colour of a value on the scale: green when low, yellow in the middle, orange when high. */
export function levelColor(share: number): string {
if (share < 0.3) return LOW;
return share < 0.7 ? MID : HIGH;
}
/**
* An index through the day as thin glowing columns: each stem grows from the floor
* under a bright cap, one after another, and low periods sit as small green dots.
*/
export function GlowColumns({
values,
max,
current = 0,
level,
name,
labels,
style,
}: GlowColumnsProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const top = max ?? Math.max(...values, 1);
const now = values[current] ?? 0;
const shown = Math.round(useCountUp(now, { instant: reduced }));
const tone = levelColor(now / top);
return (
<View
accessible
accessibilityLabel={`${name ? `${name} ` : ''}${now}${level ? `, ${level}` : ''}`}
style={[styles.card, style]}
>
<View style={styles.head}>
<Text
style={{
color: colors.highlight,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
fontVariant: ['tabular-nums'],
}}
>
{shown}
</Text>
{level ? (
<Text
numberOfLines={1}
style={[
styles.grow,
{ color: tone, fontSize: font.size.xs, fontWeight: font.weight.bold },
]}
>
{level}
</Text>
) : (
<View style={styles.grow} />
)}
{name ? (
<>
<Text style={{ color: MUTED, fontSize: font.size.xs, fontWeight: font.weight.medium }}>
{name}
</Text>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Path d="M21 3 3 10.5l7.2 3.3L13.5 21 21 3Z" fill={MUTED} />
</Svg>
</>
) : null}
</View>
<View style={styles.chart}>
{values.map((value, i) => (
<Column
key={i}
index={i}
share={Math.max(0, Math.min(1, value / top))}
current={i === current}
/>
))}
</View>
{labels ? (
<View style={styles.labels}>
{labels.map((label) => (
<Text key={label} style={{ color: MUTED, fontSize: font.size.xs - 2 }}>
{label}
</Text>
))}
</View>
) : null}
</View>
);
}
function Column({ index, share, current }: { index: number; share: number; current: boolean }) {
const reduced = useReduceMotion();
const tone = levelColor(share);
const stem = share * (CHART - CAP);
const grown = useSharedValue(reduced ? stem : 0);
useEffect(() => {
grown.set(reduced ? stem : withDelay(index * 35, withSpring(stem, spring('smooth'))));
}, [index, stem, reduced, grown]);
const rise = useAnimatedStyle(() => ({ height: Math.max(0, grown.get()) }));
const lift = useAnimatedStyle(() => ({ transform: [{ translateY: -Math.max(0, grown.get()) }] }));
return (
<View style={styles.column}>
<View style={[styles.rail, current ? styles.railNow : null]} />
<Animated.View
style={[
styles.stem,
gradient(`linear-gradient(180deg, ${tone} 0%, transparent 100%)`),
rise,
]}
/>
<Animated.View
style={[styles.cap, { backgroundColor: tone, boxShadow: `0px 0px 8px ${tone}` }, lift]}
/>
</View>
);
}
const MUTED = 'hsl(0, 0%, 62%)';
const styles = StyleSheet.create({
card: {
width: '100%',
maxWidth: 320,
alignSelf: 'center',
borderRadius: 24,
borderWidth: 1,
borderColor: 'hsla(0, 0%, 100%, 0.08)',
backgroundColor: 'hsl(0, 0%, 5%)',
padding: 16,
gap: 8,
},
head: { flexDirection: 'row', alignItems: 'center', gap: 6 },
grow: { flex: 1 },
chart: { flexDirection: 'row', height: CHART },
column: { flex: 1, alignItems: 'center', justifyContent: 'flex-end' },
rail: {
position: 'absolute',
top: 0,
bottom: 0,
width: 1,
backgroundColor: 'hsla(0, 0%, 100%, 0.12)',
},
railNow: { width: 1.5, backgroundColor: 'hsla(0, 0%, 100%, 0.42)' },
stem: { width: STEM, borderRadius: STEM / 2 },
cap: {
position: 'absolute',
bottom: 0,
width: CAP,
height: CAP,
borderRadius: CAP / 2,
},
labels: { flexDirection: 'row', justifyContent: 'space-between' },
});Installation
npx kinetik-ui add glow-columnspnpm dlx kinetik-ui add glow-columnsbunx kinetik-ui add glow-columnsyarn dlx kinetik-ui add glow-columnsThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/glow-columns.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-svg react-native-worklets - Copy the source files
components/kinetik/glow-columns.tsx
Usage
import { GlowColumns } from '@/components/kinetik/glow-columns';
<GlowColumns
values={[0, 0, 1, 2, 4, 6, 7, 8, 8, 7, 5, 3, 2, 1, 1, 0, 0, 0]}
current={5}
max={10}
level="MODERATE"
name="UVI"
labels={['12', '14', '16', '18', '20', '22']}
/>;When to use
- Sun, pollen or air index through a day.
- Any level where low, medium and high carry the meaning.
When not to use
- Exact values per period: only the current one is written out.
- Colour alone cannot carry the meaning: keep
levelon.
Notes
- Below 30% of the scale is green, below 70% yellow, and orange above.
currentsets the big number and brightens that column's rail.
Props
GlowColumnsProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuesrequired | number[] | — | One value per period, oldest first. |
max | number | — | Top of the scale. Defaults to the largest value. |
current | number | 0 | Index of the current period, which sets the big number. Defaults to 0. |
level | string | — | Word beside the big number, e.g. "MODERATE". |
name | string | — | Short name in the corner, e.g. "UVI". |
labels | string[] | — | Period labels, spread under the columns. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Stems and caps use short UI-thread springs on arrival. Nothing animates at rest.
- Measured
Huawei P20 Lite (ANE-LX1), Android 9, release build · 34 ms median frame, 40 ms p90 · no frames drawn at rest · 124 MB app memory
Accessibility
- Reduced motion
- Columns appear in place and the number does not count.
- Screen readers
- One label ("UVI 6, MODERATE").
- Touch target
- Not interactive.
Source files
components/kinetik/glow-columns.tsx is in the Code tab of the preview above.