Skip to main content

Styling

Kog styling maps React Native's StyleSheet model onto LVGL's style system. The two fit unusually well, because both want you to define styles once and share them.

import { StyleSheet } from 'react-native';

const styles = StyleSheet.create({
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
gap: 8,
shadowColor: '#000000',
shadowOpacity: 0.1,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
},
title: { fontSize: 18, color: '#111111' },
});

There is no CSS: importing a .css file is a compile error, and className is an unknown prop. Style with StyleSheet.create and the style prop.

StyleSheet.create is resolved at build time: colors become integers, enums become numbers, and each unique style becomes a shared native style object, used by every widget that references it. That is exactly how LVGL wants styles used for memory efficiency, so the idiom you already know is also the optimal one.

Layout is flexbox​

LVGL has a real flexbox engine, so RN's layout props map directly:

flexDirection, justifyContent, alignItems, alignSelf, flexWrap, flex, gap/rowGap/columnGap, padding*, margin*, width/height/min*/max*.

  • Every View is a flex container and flexDirection defaults to column, exactly like React Native: a container with no explicit direction stacks its children vertically, whether or not it sets any other flex prop. An authored flexDirection always wins, and a later style that only sets gap doesn't reset a row to a column.
  • alignItems defaults to stretch, exactly like React Native: children without an explicit cross-axis size fill the container (a { height: 20 } bar spans the full width; justifyContent: 'space-between' headers work without sizing the header).
  • alignSelf overrides the container's alignment per child, including alignSelf: 'stretch' / 'flex-end' inside an alignItems: 'flex-start' row.
  • Two React Native values are absent because LVGL can't draw them: 'baseline' for alignItems/alignSelf, and 'justify' for textAlign. Using either is a type error.
  • Numbers are pixels (embedded displays are fixed-density; there's no dp). Fractional values round to the nearest pixel, as in React Native.
  • Percentages work: width: '50%'.
  • position: 'absolute' with top/left/right/bottom for overlays. All four insets work, in styles or as element props (bottom: 5, right: 5 pins to that corner), and left plus right (or top plus bottom) with no size stretches the element between them. Insets measure from the parent's content box, inside its padding (React Native uses the padding box). StyleSheet.absoluteFill covers the parent.
  • lineHeight is LVGL line spacing (extra pixels between lines), not RN's total line height.
  • Text styles (color, fontSize, …) inherit through containers into Text and widget labels (a Checkbox's text), so one root color sets your app's base ink.

Colors​

Colors take React Native's forms: '#rgb', '#rgba', '#rrggbb', '#rrggbbaa', 'rgb(r, g, b)', 'rgba(r, g, b, a)' (with a from 0 to 1) and 'transparent'. An alpha below 1 blends, and an alpha of 0 is fully transparent. That holds for every color prop, including text color and the Arc track and indicator colors, so translucent text blends as in React Native. Named colors other than 'transparent' (such as 'red') and hsl() are not supported; they are errors that list the accepted forms. backgroundOpacity and borderOpacity are Kog extensions that fade just the background or the border.

Transforms​

transform takes React Native's array of single-key objects, applied about the element's center by default:

const styles = StyleSheet.create({
tilted: { transform: [{ rotate: '15deg' }, { scale: 1.2 }] },
hand: { transform: [{ rotate: '90deg' }], transformOrigin: '50% 100%' },
});
  • rotate takes 'deg' or 'rad' strings, as in React Native, such as '45deg', '-1.5rad' or '.5rad'; a bare number, an exponent, a + sign or hex is an error, both at build time and at runtime. scale, scaleX and scaleY take numbers; translateX and translateY take pixels; skewX and skewY take angle strings.
  • transformOrigin takes '50% 50%', keywords such as 'top left', or pixel offsets such as '10px 20px'.
  • perspective and matrix are errors (transform 'perspective' is not supported: LVGL renders 2D transforms only: …).
  • A transform replaces the whole transform, as in React Native: entries you leave out return to identity.
  • LVGL applies the parts in a fixed order (translate, then scale and rotate about the origin), not in list order as React Native does. A list whose result depends on the order, such as a translate before a rotate, renders differently. Each kind may appear once. A repeat is an error, and rotate and rotateZ count as the same kind: [{ rotate: '1deg' }, { rotateZ: '2deg' }] gives transform sets 'rotateZ' twice: Kog applies one value per transform (LVGL has no matrix composition); combine them into one entry.
  • scale can be combined with scaleX/scaleY only when all of them are literal numbers; for dynamic values use scaleX and scaleY alone.
  • A transform value can be dynamic, for example style={{ transform: [{ rotate: `${angle}deg` }] }}; each entry updates its own native property.

Avoid putting a transform in a pressed style. On a real touch panel, a widget that rotates or scales while pressed can lose the press, so onPress never fires. Pressed styles should change color, opacity or border.

Shadows​

Shadows use React Native's keys: shadowColor, shadowOpacity, shadowRadius and shadowOffset: { width, height }. An axis missing from shadowOffset is 0, whether the object is literal or built at runtime, and an axis that isn't a number is an error. elevation: n is shorthand for a shadow of radius 2n, offset n pixels down. It uses LVGL's plain shadow, not Android's soft material shadow, so set shadowColor and shadowOpacity for a subtler look.

Overflow​

overflow decides what happens to children that extend past a view's edges:

ValueEffect
not setChildren are clipped to the view's box, and the view doesn't scroll. This differs from React Native, whose default is 'visible'.
'visible'Children draw outside the view, and the view doesn't scroll. Because the default clips, every ancestor a child reaches past must also be 'visible'.
'hidden'Children are clipped to the view, including its rounded corners.
'scroll'The view scrolls, without clipping to its rounded corners (in React Native a plain View with overflow: 'scroll' does not scroll). Prefer ScrollView, which also takes scrollEnabled.

Kog's old style keys​

Earlier versions of Kog had their own names for a few styles. They are now errors that name the React Native key, in a style or as an element prop, and also in a style object built at runtime:

line 12:5: 'rotation' is not a React Native style: use transform: [{ rotate: '45deg' }] (React Native).
Old keyUse instead
rotation: 45transform: [{ rotate: '45deg' }]
scale: 2transform: [{ scale: 2 }]
transformOriginX, transformOriginYtransformOrigin: '10px 20px'
shadowWidthshadowRadius
shadowOffsetX, shadowOffsetYshadowOffset: { width, height }
translateX, translateY, scaleX, scaleY, skewX, skewY as style keystransform: [{ translateX: 10 }], and so on
hidden: truedisplay: 'none'
numberOfLines in a stylethe Text prop: <Text numberOfLines={2}>
scrollableoverflow: 'scroll' or 'visible', or ScrollView's scrollEnabled
clipCorneroverflow: 'hidden'

Style keys Kog doesn't support​

StyleSheet.create, inline styles and style objects built at runtime (props.style, a style computed in code) are checked identically: the same keys are accepted, and the same errors are raised, prefixed kog: at runtime. A few React Native keys are aliases: flexGrow is flex, and start/end, marginStart/marginEnd and paddingStart/paddingEnd are their left-to-right forms. flexShrink accepts only 0; any other value is an error (flexShrink is not supported: LVGL flex items never shrink. Size items explicitly.). includeFontPadding and backfaceVisibility are accepted and ignored. Two keys in one style object that set the same thing, such as flex with flexGrow, or elevation with shadowRadius or shadowOffset, are an error ('flexGrow' and 'flex' both set …: keep one of them.); across a style array, the later entry wins as usual.

Every other React Native style key that Kog lacks is a build error that gives the reason and, where there is one, the replacement:

line 4:20: style 'borderTopWidth' is not supported on Kog: LVGL draws one border width for all sides: use borderWidth.

That covers the per-side border widths and colors, per-corner radii, outline*, boxShadow, filter, inset*, the Block/Inline margins and paddings, direction, textShadow*, fontStyle, fontVariant, textTransform, aspectRatio, flexBasis, objectFit and the rest.

backgroundOpacity and borderOpacity remain as Kog extensions: they fade just the background or the border, and they can be animated.

Dynamic styles​

Static styles cost nothing at runtime. When style membership depends on state, the element's style list re-applies when the state flips. The properties of the style that dropped out un-apply, so nothing goes stale:

<Text style={ok ? styles.pass : styles.fail}>status</Text> // conditional style
<View style={[styles.dot, isOn && styles.dotLit]} /> // conditional merge
<View style={{ width: `${progress}%` }} /> // dynamic inline value
<Pressable style={({ pressed }) => pressed && styles.down} /> // interaction states

A Pressable function style compiles to native state styles, so a press runs no JS. Use one of these shapes: pressed ? a : b, pressed && a (or !pressed), an array of those ([styles.base, pressed && styles.down]), or a const x = pressed ? a : b; line before the return. The other states (focused, disabled, checked, scrolled) work the same way. Anything else, such as { opacity: pressed ? 0.5 : 1 }, is a compile error that lists these shapes, and the state branch must be a static style (styles.x or a literal object). One difference from React Native: the non-pressed styles stay applied while pressed, and the pressed style layers over them, like CSS :active. A property set only in the non-pressed branch therefore stays in effect during the press, so set every property that should change in both branches. For a button that only dims while pressed, TouchableOpacity does it natively with no style function at all.

Style arrays merge left-to-right (later wins); falsy entries (cond && style) are skipped, exactly like React Native. A fully static style list is applied once at mount, at zero runtime cost. Interaction states (pressed, focused, disabled, checked) map to LVGL's native state selectors.

Inline style objects can hold dynamic values, as in React Native: in style={{ width: w, opacity: 0.5 }} the literal keys become a shared static style and width becomes a property set on that element, updated in place when w changes. A few rules follow from that:

  • Later entries in a style array win, as in React Native, including over a dynamic inline value: in style={[{ width: w }, props.style]}, a width in props.style wins. The one exception is the composite keys transform, transformOrigin, shadowOffset, shadowRadius and elevation: set dynamically in an inline object, they still outrank a later entry the compiler can't see, such as props.style or an imported style, so put such an inline object last. When a later literal object, or an entry of a StyleSheet.create in the same file, sets the same key as a dynamic inline value, the build reports it.
  • A spread in a style array (style={[styles.a, ...more]}) applies more's entries in place, in order.
  • An inline object chosen by a condition (cond && { … } or a ? { … } : { … }) must be all literals. Put the condition inside the value instead: [base, { width: big ? 200 : 100 }].
  • An inline style passed to your own component (not a built-in element) must be all literals.
  • Values inside StyleSheet.create must be literals, because the sheet is resolved at build time.

For smooth transitions, use Animated: put an Animated.Value in an Animated.View's style and call Animated.timing(value, { toValue, duration }).start(). The native side interpolates each frame in C, with no JavaScript per frame. An animated value uses the property's own units, so animated opacity is 0–1, the same as static opacity.

Theming and dark mode​

import { Appearance, useColorScheme } from 'react-native';
import { DynamicColor } from '@kog/ui';

Appearance.setColorScheme('light'); // there is no OS theme: the app chooses
const scheme = useColorScheme(); // 'light' | 'dark', updates when the scheme changes
const bg = DynamicColor('#fff', '#111'); // Kog extension: (light, dark), resolved once

A device has no system theme, so Appearance.getColorScheme() starts at 'dark' and your app sets it with Appearance.setColorScheme. useColorScheme() reads it, and the expressions that use its result update when it changes, without remounting: color: scheme === 'dark' ? '#eee' : '#111' follows the scheme. Two Kog extensions from @kog/ui complete the picture. <ThemeProvider scheme="light"> overrides the scheme for its subtree (without scheme it follows Appearance). DynamicColor(light, dark) picks the color for the scheme at the moment it is called; it does not update later, so use the useColorScheme() form for colors that must follow a change.

Fonts and glyph coverage​

Text renders with LVGL's built-in Montserrat font, which covers printable ASCII plus °, •, and LVGL's built-in symbols. Other non-ASCII characters, such as emoji, em-dashes (—), typographic quotes, and arrows (→), show as blank boxes on the device and in the simulator. Keep rendered strings ASCII (--, ->, straight quotes). fontWeight and fontFamily are accepted but not yet applied on device (textDecorationLine and numberOfLines are); fontSize maps to the nearest compiled-in Montserrat size. fontWeight takes React Native's full set ('normal', 'bold', '100' to '900', or a number) and lowers to two weights: normal below 600 and bold from 600 up. Bundling custom fonts for wider glyph coverage is planned.