mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
574 lines
18 KiB
Plaintext
574 lines
18 KiB
Plaintext
---
|
|
title: Styling
|
|
description: Gui accepts a superset of React Native styles
|
|
---
|
|
|
|
Gui supports a superset of the React Native style properties on to any
|
|
styled component like the base
|
|
[View and Text components](/docs/core/view-and-text), or through the
|
|
[`styled()` function](/docs/core/styled) as the second argument.
|
|
|
|
Here's how that looks in practice:
|
|
|
|
```tsx
|
|
import { View, styled } from '@hanzogui/core'
|
|
|
|
const StyledView = styled(View, {
|
|
padding: 10,
|
|
})
|
|
|
|
const MyView = () => (
|
|
<StyledView
|
|
backgroundColor="red"
|
|
hoverStyle={{
|
|
backgroundColor: 'green',
|
|
}}
|
|
/>
|
|
)
|
|
```
|
|
|
|
The types for the full set of styles accepted by styled, View and Text are
|
|
exported as `ViewStyle` and `TextStyle`.
|
|
|
|
For the full base styles, see the React Native docs (version 2 targets React
|
|
Native 0.82):
|
|
|
|
- [React Native View style props](https://reactnative.dev/docs/view-style-props)
|
|
- [React Native Text style props](https://reactnative.dev/docs/text-style-props)
|
|
|
|
The full Gui typed style props can be simplified to something like this,
|
|
except the values can also accept one of your design tokens:
|
|
|
|
```tsx
|
|
import { ViewStyle as RNViewStyle } from 'react-native'
|
|
|
|
type BaseViewStyle = RNViewStyle & FlatTransformStyles & CrossPlatformStyles & WebOnlyStyles
|
|
|
|
// Cross-platform styles:
|
|
type CrossPlatformStyles = {
|
|
boxShadow?: BoxShadowValue // string, object, or array
|
|
filter?: FilterValue // brightness, opacity cross-platform; blur, contrast, etc. Android 12+
|
|
backgroundImage?: string // linear-gradient, radial-gradient; supports $tokens
|
|
cursor?: Properties['cursor'] // web + iOS 17+ (trackpad/stylus/gaze)
|
|
mixBlendMode?: 'normal' | 'multiply' | 'screen' | 'overlay' | ... // blend modes
|
|
isolation?: 'auto' | 'isolate'
|
|
boxSizing?: 'border-box' | 'content-box'
|
|
display?: 'flex' | 'grid' | 'none' | 'contents' | ...
|
|
position?: 'absolute' | 'relative' | 'fixed' | 'static' | 'sticky' // 'fixed' converts to 'absolute' on native
|
|
border?: BorderValue // shorthand: "1px solid $borderColor"; expands on native
|
|
outline?: OutlineValue // shorthand: "2px solid $outlineColor"; expands on native
|
|
outlineColor?: ColorValue
|
|
outlineOffset?: SpaceValue
|
|
outlineStyle?: 'solid' | 'dotted' | 'dashed'
|
|
outlineWidth?: SpaceValue
|
|
}
|
|
|
|
// these are accepted but only render on web:
|
|
type WebOnlyStyles = {
|
|
contain?: Properties['contain']
|
|
touchAction?: Properties['touchAction']
|
|
backdropFilter?: Properties['backdropFilter']
|
|
backgroundOrigin: Properties['backgroundOrigin'],
|
|
backgroundPosition: Properties['backgroundPosition'],
|
|
backgroundRepeat: Properties['backgroundRepeat'],
|
|
backgroundSize: Properties['backgroundSize']
|
|
backgroundColor: Properties['backgroundColor']
|
|
backgroundClip: Properties['backgroundClip']
|
|
backgroundBlendMode: Properties['backgroundBlendMode']
|
|
backgroundAttachment: Properties['backgroundAttachment']
|
|
background: Properties['background']
|
|
clipPath: Properties['clipPath'],
|
|
caretColor: Properties['caretColor']
|
|
transformStyle: Properties['transformStyle'],
|
|
mask: Properties['mask'],
|
|
maskImage: Properties['maskImage'],
|
|
textEmphasis: Properties['textEmphasis'],
|
|
borderImage: Properties['borderImage'],
|
|
float: Properties['float']
|
|
content: Properties['content']
|
|
overflowBlock: Properties['overflowBlock']
|
|
overflowInline: Properties['overflowInline']
|
|
maskBorder: Properties['maskBorder']
|
|
maskBorderMode: Properties['maskBorderMode']
|
|
maskBorderOutset: Properties['maskBorderOutset']
|
|
maskBorderRepeat: Properties['maskBorderRepeat']
|
|
maskBorderSlice: Properties['maskBorderSlice']
|
|
maskBorderSource: Properties['maskBorderSource']
|
|
maskBorderWidth: Properties['maskBorderWidth']
|
|
maskClip: Properties['maskClip']
|
|
maskComposite: Properties['maskComposite']
|
|
maskMode: Properties['maskMode']
|
|
maskOrigin: Properties['maskOrigin']
|
|
maskPosition: Properties['maskPosition']
|
|
maskRepeat: Properties['maskRepeat']
|
|
maskSize: Properties['maskSize']
|
|
maskType: Properties['maskType']
|
|
}
|
|
|
|
// these turn into the equivalent transform style props:
|
|
type FlatTransformStyles = {
|
|
x?: number
|
|
y?: number
|
|
perspective?: number
|
|
scale?: number
|
|
scaleX?: number
|
|
scaleY?: number
|
|
skewX?: string
|
|
skewY?: string
|
|
matrix?: number[]
|
|
rotate?: string
|
|
rotateY?: string
|
|
rotateX?: string
|
|
rotateZ?: string
|
|
}
|
|
|
|
// add the pseudo and enter/exit style states
|
|
type WithStates = BaseViewStyle & {
|
|
hoverStyle?: BaseViewStyle
|
|
pressStyle?: BaseViewStyle
|
|
focusStyle?: BaseViewStyle
|
|
focusVisibleStyle?: BaseViewStyle
|
|
focusWithinStyle?: BaseViewStyle
|
|
disabledStyle?: BaseViewStyle
|
|
enterStyle?: BaseViewStyle
|
|
exitStyle?: BaseViewStyle
|
|
}
|
|
|
|
// final View style props
|
|
type ViewStyle = WithStates & {
|
|
// add media queries
|
|
$sm?: WithStates
|
|
|
|
// add group queries
|
|
$group-hover?: WithStates
|
|
$group-focus?: WithStates
|
|
$group-press?: WithStates
|
|
|
|
// add group + container queries
|
|
$group-sm-hover?: WithStates
|
|
$group-sm-focus?: WithStates
|
|
$group-sm-press?: WithStates
|
|
|
|
// add named group queries
|
|
$group-tabs?: WithStates
|
|
$group-tabs-hover?: WithStates
|
|
$group-tabs-focus?: WithStates
|
|
$group-tabs-press?: WithStates
|
|
|
|
// add named group + container queries
|
|
$group-tabs-sm?: WithStates
|
|
$group-tabs-sm-hover?: WithStates
|
|
$group-tabs-sm-focus?: WithStates
|
|
$group-tabs-sm-press?: WithStates
|
|
|
|
// add theme queries
|
|
$theme-light?: WithStates
|
|
$theme-dark?: WithStates
|
|
|
|
// add platform queries
|
|
$platform-native?: WithStates
|
|
$platform-ios?: WithStates
|
|
$platform-android?: WithStates
|
|
$platform-web?: WithStates
|
|
}
|
|
|
|
// Text style starts with this base but builds up the same:
|
|
type TextStyleBase = BaseViewStyle & {
|
|
color?: string,
|
|
fontFamily?: string,
|
|
fontSize?: string,
|
|
fontStyle?: string,
|
|
fontWeight?: string,
|
|
letterSpacing?: string,
|
|
lineHeight?: string,
|
|
textAlign?: string,
|
|
textDecorationColor?: string,
|
|
textDecorationLine?: string,
|
|
textDecorationStyle?: string,
|
|
textShadowColor?: string,
|
|
textShadowOffset?: string,
|
|
textShadowRadius?: string,
|
|
textTransform?: string,
|
|
}
|
|
```
|
|
|
|
## Pseudo States
|
|
|
|
Gui supports several style states that apply based on interaction or focus:
|
|
|
|
- **`hoverStyle`** - Styles applied when hovering over the element (web only, maps to CSS `:hover`)
|
|
- **`pressStyle`** - Styles applied while the element is being pressed
|
|
- **`focusStyle`** - Styles applied when the element has focus (maps to CSS `:focus`)
|
|
- **`focusVisibleStyle`** - Styles applied when the element has keyboard focus (maps to CSS `:focus-visible`). Use this for keyboard navigation indicators.
|
|
- **`focusWithinStyle`** - Styles applied when any child element has focus (maps to CSS `:focus-within`). Useful for styling containers when an input inside them is focused.
|
|
- **`disabledStyle`** - Styles applied when `disabled={true}` is set
|
|
|
|
```tsx
|
|
<View
|
|
backgroundColor="$background"
|
|
hoverStyle={{ backgroundColor: '$backgroundHover' }}
|
|
pressStyle={{ backgroundColor: '$backgroundPress', scale: 0.98 }}
|
|
focusStyle={{ outlineColor: '$blue10', outlineWidth: 2, outlineStyle: 'solid' }}
|
|
focusVisibleStyle={{ outlineColor: '$blue10', outlineWidth: 2 }}
|
|
focusWithinStyle={{ borderColor: '$blue10' }}
|
|
disabledStyle={{ opacity: 0.5 }}
|
|
/>
|
|
```
|
|
|
|
### Enter and Exit Styles
|
|
|
|
For mount/unmount animations, use:
|
|
|
|
- **`enterStyle`** - Initial styles when the component mounts (animates _from_ these values)
|
|
- **`exitStyle`** - Final styles when the component unmounts (animates _to_ these values)
|
|
|
|
See [Animations](/docs/core/animations) for more details on enter/exit animations.
|
|
|
|
## rem Units
|
|
|
|
Gui supports `rem` units cross-platform. On web, browsers handle rem natively. On native, Gui converts rem values to pixels using `PixelRatio.getFontScale()` to respect the user's font size preferences.
|
|
|
|
```tsx
|
|
// works on both web and native
|
|
<View padding="1rem" />
|
|
<Text fontSize="1.5rem" />
|
|
```
|
|
|
|
You can configure the base font size (default 16) in your config:
|
|
|
|
```tsx
|
|
createGui({
|
|
settings: {
|
|
remBaseFontSize: 16, // default
|
|
},
|
|
})
|
|
```
|
|
|
|
On native, `1rem` with a base of 16 equals `16 * PixelRatio.getFontScale()` pixels, scaling with the user's accessibility settings.
|
|
|
|
## CSS Shorthand with Variables
|
|
|
|
Gui supports using `$variables` directly inside CSS shorthand string values.
|
|
This is particularly useful for `boxShadow` and `backgroundImage` where you want
|
|
to reference theme colors:
|
|
|
|
```tsx
|
|
// boxShadow with variables
|
|
<View boxShadow="0 0 10px $shadowColor" />
|
|
<View boxShadow="0 0 5px $shadowColor, 0 0 15px $color" />
|
|
|
|
// backgroundImage with linear-gradient (cross-platform, RN 0.76+)
|
|
<View backgroundImage="linear-gradient(to bottom, $background, $color)" />
|
|
<View backgroundImage="linear-gradient(45deg, $black 0%, $white 50%, $black 100%)" />
|
|
|
|
// filter with variables
|
|
<View filter="blur($2)" />
|
|
|
|
// On web: resolves to CSS custom properties
|
|
// boxShadow: "0 0 10px var(--t-shadow-shadowColor)"
|
|
// backgroundImage: "linear-gradient(to bottom, var(--background), var(--color))"
|
|
|
|
// On native: resolves to raw values
|
|
// boxShadow: "0 0 10px rgba(0,0,0,0.2)"
|
|
// backgroundImage: "linear-gradient(to bottom, #fff, #000)"
|
|
```
|
|
|
|
Supported shorthand properties with variable resolution:
|
|
|
|
- `boxShadow` - Works on both web and native
|
|
- `backgroundImage` - Works on both web and native (see below)
|
|
- `filter` - Works on both web and native
|
|
- `border` - Works on both web and native (expands to borderWidth/Style/Color on native)
|
|
- `outline` - Works on both web and native (expands to outlineWidth/Style/Color on native)
|
|
- `borderTop`, `borderRight`, `borderBottom`, `borderLeft` - Web only
|
|
- `background` - Web only
|
|
|
|
### border
|
|
|
|
The `border` shorthand now works cross-platform:
|
|
|
|
```tsx
|
|
// Works on both web and native
|
|
<View border="1px solid $borderColor" />
|
|
<View border="2px dashed red" />
|
|
<View border="none" />
|
|
```
|
|
|
|
On web, the CSS shorthand is passed through directly. On native, it expands to individual properties:
|
|
|
|
- `borderWidth` → `borderTopWidth`, `borderRightWidth`, `borderBottomWidth`, `borderLeftWidth`
|
|
- `borderStyle` → `borderStyle`
|
|
- `borderColor` → `borderTopColor`, `borderRightColor`, `borderBottomColor`, `borderLeftColor`
|
|
|
|
Note: On native, only a single border (all sides) is supported. For per-side borders on native, use individual props.
|
|
|
|
### outline
|
|
|
|
The `outline` shorthand works cross-platform, similar to `border`:
|
|
|
|
```tsx
|
|
// Works on both web and native
|
|
<View outline="2px solid $outlineColor" />
|
|
<View outline="1px dashed red" />
|
|
<View outline="none" />
|
|
```
|
|
|
|
On web, the CSS shorthand is passed through directly. On native, it expands to `outlineWidth`, `outlineStyle`, and `outlineColor`.
|
|
|
|
### backgroundImage
|
|
|
|
Supports `linear-gradient()` and `radial-gradient()` cross-platform. Does not
|
|
support `url()` on native - use `<ImageBackground>` for images.
|
|
|
|
```tsx
|
|
<View backgroundImage="linear-gradient(to bottom, $background, $color)" />
|
|
<View backgroundImage="radial-gradient(circle, $white, $black)" />
|
|
```
|
|
|
|
### textShadow
|
|
|
|
Text shadows support CSS shorthand with token resolution, just like `boxShadow`:
|
|
|
|
```tsx
|
|
import { Text } from '@hanzo/gui'
|
|
|
|
// CSS shorthand with tokens
|
|
<Text textShadow="2px 2px 4px $shadowColor">
|
|
Shadowed Text
|
|
</Text>
|
|
|
|
// multiple shadows (web only)
|
|
<Text textShadow="1px 1px 2px $shadowColor, 0 0 8px $color">
|
|
Glowing Text
|
|
</Text>
|
|
```
|
|
|
|
You can also use the individual React Native props:
|
|
|
|
```tsx
|
|
<Text
|
|
textShadowColor="$shadowColor"
|
|
textShadowOffset={{ width: 2, height: 2 }}
|
|
textShadowRadius={4}
|
|
>
|
|
Shadowed Text
|
|
</Text>
|
|
```
|
|
|
|
On native, only a single text shadow is supported (React Native limitation).
|
|
|
|
## Parent based styling
|
|
|
|
Gui has a variety of ways to style a child based on the "parent", a parent
|
|
being one of: platform, screen size, theme, or group. All of these styles use
|
|
the same pattern, they use a `$` prefix for their styles, and they nest styles
|
|
as a sub-object.
|
|
|
|
For example you can target `$theme-light`, `$platform-ios`, or `$group-header`.
|
|
For screen size, which we call media queries, they have no prefix. Instead you
|
|
define media queries on `createGui`. For example, if you define a media
|
|
query named `large`, then `$large` is the prop name.
|
|
|
|
These parent style props accept all the Gui style props in their value
|
|
object.
|
|
|
|
#### Media query
|
|
|
|
Based on whatever media queries you define in `createGui`, you can now use
|
|
any of them to apply styling on native and web using the `$` prefix.
|
|
|
|
If you defined your media query like:
|
|
|
|
```tsx
|
|
createGui({
|
|
media: {
|
|
sm: { maxWidth: 800 },
|
|
},
|
|
})
|
|
```
|
|
|
|
Then you can use it like:
|
|
|
|
```tsx
|
|
<Text color="red" $sm={{ color: 'blue' }} />
|
|
```
|
|
|
|
### Theme
|
|
|
|
Theme style props let you style a child based on a parent theme. At the moment,
|
|
they only can target your top level themes, so if you have `light`, and
|
|
`light_subtle` themes, then only `light` can be targeted.
|
|
|
|
Use them like so:
|
|
|
|
```tsx
|
|
<Text $theme-dark={{ color: 'white' }} />
|
|
```
|
|
|
|
### Platform
|
|
|
|
Platform style props let you style a child based on the platform the app is
|
|
running on. This can be one of `ios`, `android`, `web`, or `native` (iOS and
|
|
Android).
|
|
|
|
Use it like so:
|
|
|
|
```tsx
|
|
<Text $platform-ios={{ color: 'white' }} />
|
|
```
|
|
|
|
### Group
|
|
|
|
Groups are a new feature in beta that lets you define a named group, and then
|
|
style children based whether they are inside a parent that is given that group
|
|
name.
|
|
|
|
A short example:
|
|
|
|
```tsx
|
|
<View group="header">
|
|
<Text $group-header={{ color: 'white' }} />
|
|
</View>
|
|
```
|
|
|
|
This will make the Text turn white, as it's inside a parent item with `group`
|
|
set to the matching `header` value.
|
|
|
|
Group styles also allow for targeting the parent pseudo state:
|
|
|
|
```tsx
|
|
<View group>
|
|
<Text $group-hover={{ color: 'white' }} />
|
|
</View>
|
|
```
|
|
|
|
Now only when the parent View is hovered the Text will turn white. The allowed
|
|
pseudo modifiers are `hover` (web only), `press`, and `focus`.
|
|
|
|
For more advanced use cases you can use named groups:
|
|
|
|
```tsx
|
|
<View group="card">
|
|
<Text>Outer</Text>
|
|
<View group>
|
|
<Text $group-card-hover={{ color: 'blue' }}>Inner</Text>
|
|
<Text $group-hover={{ color: 'green' }}>Sibling</Text>
|
|
</View>
|
|
</View>
|
|
```
|
|
|
|
Now the `Inner` Text will turn blue when the `card` group is hovered, and the
|
|
`Sibling` Text will turn green when its parent is hovered.
|
|
|
|
To make this typed, you need to set `TypeOverride` alongside the same area you
|
|
set up your Gui types:
|
|
|
|
```tsx
|
|
declare module 'hanzogui' {
|
|
interface GuiCustomConfig extends AppConfig {}
|
|
|
|
// if you want types for named group styling props (e.g. $group-card-hover),
|
|
// define your group names here:
|
|
interface TypeOverride {
|
|
groupNames(): 'card' | 'header' | 'sidebar'
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Group Container
|
|
|
|
The final feature of group styles is the ability to style a child only when the
|
|
parent is of a certain size. On the web these are known as "container queries",
|
|
which is what Gui outputs as CSS under the hood. They look like this:
|
|
|
|
```tsx
|
|
<View group>
|
|
<Text $group-sm={{ color: 'white' }} $group-sm-hover={{ color: 'green' }} />
|
|
</View>
|
|
```
|
|
|
|
Now the Text will be white, but only when the View matches the media query `sm`.
|
|
This uses the same media query breakpoints you defined in
|
|
`createGui({ media })`. You can see it also works with pseudo styles!
|
|
|
|
For more advanced use cases, you can use named groups with container queries:
|
|
|
|
```tsx
|
|
<View group="card">
|
|
<View group>
|
|
<Text $group-card-sm={{ color: 'white' }} $group-card-sm-hover={{ color: 'green' }} />
|
|
<Text $group-sm={{ color: 'white' }} $group-sm-hover={{ color: 'green' }} />
|
|
</View>
|
|
</View>
|
|
```
|
|
|
|
Now the first Text will be white when the `card` parent matches `sm`, and the
|
|
second Text will be white when no named parent matches `sm`.
|
|
|
|
_A note on group containers and native_
|
|
|
|
On Native, we don't have access to the layout of a React component as it first
|
|
renders. Only once we get the dimensions from the `onLayout` callback after the
|
|
first render are we able to apply group container styles.
|
|
|
|
Because of this, we've done two things.
|
|
|
|
First, there's a new property `untilMeasured`:
|
|
|
|
```tsx
|
|
<View group untilMeasured="hide">
|
|
<Text
|
|
$group-sm={{
|
|
color: 'white',
|
|
}}
|
|
/>
|
|
</View>
|
|
```
|
|
|
|
This takes two options, `show` or `hide`. If unset it defaults to `show`, which
|
|
means it will render before it measures. With `hide` set, the container will be
|
|
set to `opacity` 0 until it finishes measuring.
|
|
|
|
Alternatively, if you know the dimensions your container will be up-front, you
|
|
can set width and height on the container. When either of these are set, the
|
|
children will attempt to use these values on first render and if they satisfy
|
|
the media query, you'll avoid the need for a double render altogether.
|
|
|
|
## Order is important
|
|
|
|
It's important to note that the order of style properties is significant. This
|
|
is really important for two reasons:
|
|
|
|
1. You want to control which styles are overridden.
|
|
2. You have a variant that expands into multiple style properties, and you need
|
|
to control it.
|
|
|
|
Let's see how it lets us control overriding styles:
|
|
|
|
```tsx
|
|
import { View, ViewProps } from '@hanzogui/core'
|
|
|
|
export default (props: ViewProps) => <View background="red" {...props} width={200} />
|
|
```
|
|
|
|
In this case we set a default `background` to red, but it can be overridden by
|
|
props. But we set `width` _after_ the prop spread, so width is _always_ going to
|
|
be set to 200.
|
|
|
|
It also is necessary for variants to make sense. Say we have a variant `huge`
|
|
that sets `scale` to 2 and `borderRadius` to 100:
|
|
|
|
```tsx
|
|
// this will be scale = 3
|
|
export default (props: ViewProps) => <MyView huge scale={3} />
|
|
|
|
// this will be scale = 2
|
|
export default (props: ViewProps) => <MyView scale={3} huge />
|
|
```
|
|
|
|
If order wasn't important, how would you expect these two different usages to
|
|
work? You'd have to make order important _somewhere_. If you do it in the
|
|
`styled()` helper somewhere, you end up having no flexibility and would end up
|
|
with boilerplate. Making the prop order important gives us maximum
|
|
expressiveness and is easy to understand.
|