Skip to content

05. 디자인 토큰과 테마 전환 — `Animate`와 `Modifier.Overridden`

This content is not available in your language yet.

대상 독자: 다크/라이트 테마와 재사용 가능한 스타일 아키텍처를 만들려는 개발자 다루는 개념: 디자인 토큰, q.Context, state:Apply(q.Animate{...}), Modifier.Overridden

-- 설치 경로는 프로젝트 구성에 따라 다르다(00-installation 참고)
local Quad = require(<quad-base 모듈 경로>)
local QuadRoblox = require(<quad-roblox 모듈 경로>).QuadRoblox
local q = Quad:UseProvider(QuadRoblox) -- quad-roblox 백엔드 설치: D/Tween/Animate/OnChange가 생긴다
local D = q.D
-- 클래스별 Modifier 타입(`FrameModifier`/`IntoFrame` 등)은 생성된 D 모듈에서 가져온다
local DTypes = require(<quad-roblox D 모듈 경로>)

색을 컴포넌트마다 하드코딩하는 대신, 의미 역할로 이름을 붙입니다 — Background, Surface, TextPrimary, TextMuted, Accent, Border.

quad에는 트리를 훑어 올라가는 암묵적 컨텍스트가 없습니다. 토큰을 아래로 내리는 방법은 둘입니다.

  • props로 직접 넘기기 — 층이 얕으면 이게 가장 단순합니다.
  • q.Context — 중간 층이 앱의 스토어 스키마를 몰라도 가방 하나를 그대로 전달할 수 있는 명시적 값 컨테이너입니다. 트리 탐색은 여전히 없습니다: 컴포넌트는 가방을 이름 붙은 파라미터로 받아 자기 키만 읽습니다.
local ThemeProvider = q.Context.Provider("Theme")
-- 앱 진입점에서 한 번
local ctx = q.Context():Set(ThemeProvider, Theme)
-- 아래 어딘가에서
local theme = ctx:Get(ThemeProvider) -- 없으면 에러
local maybe = ctx:Peek(ThemeProvider) -- 없으면 nil
  • Context.Provider(name?)가 돌려주는 것은 테이블 신원 키입니다 — 모듈 간 문자열 충돌이 없습니다. 이름은 에러 메시지용 선택 인자입니다.
  • :Set가방 자신을 변경하고 자기를 돌려줍니다(체이닝). 값이 nil이면 에러입니다 — “없음”은 안 넣는 것으로 표현합니다.
  • :Get은 없으면 에러, :Peeknil입니다. 가방을 열거하는 표면은 일부러 없습니다.

-- Theme.luau
export type ColorPalette = {
Background: Color3,
Surface: Color3,
TextPrimary: Color3,
TextMuted: Color3,
Accent: Color3,
Border: Color3,
}
local LIGHT: ColorPalette = {
Background = Color3.fromRGB(248, 249, 250),
Surface = Color3.fromRGB(255, 255, 255),
TextPrimary = Color3.fromRGB(33, 37, 41),
TextMuted = Color3.fromRGB(108, 117, 125),
Accent = Color3.fromRGB(13, 110, 253),
Border = Color3.fromRGB(222, 226, 230),
}
local DARK: ColorPalette = {
Background = Color3.fromRGB(18, 18, 18),
Surface = Color3.fromRGB(30, 30, 30),
TextPrimary = Color3.fromRGB(240, 240, 240),
TextMuted = Color3.fromRGB(160, 160, 160),
Accent = Color3.fromRGB(66, 153, 225),
Border = Color3.fromRGB(45, 45, 45),
}
local isDark = q.Source(true)
-- 토큰 하나 = "팔레트에서 색을 뽑는 State"에 애니메이션을 얹은 것
local function token(pick: (ColorPalette) -> Color3)
return isDark
:Compute(function(dark)
return pick(if dark:Get() then DARK else LIGHT)
end)
:Apply(q.Animate({
Time = 0.25,
Style = Enum.EasingStyle.Quad,
Direction = Enum.EasingDirection.Out,
}))
end
return {
IsDark = isDark,
Toggle = function()
isDark:Set(not isDark:Get())
end,
Tokens = {
Background = token(function(p) return p.Background end),
Surface = token(function(p) return p.Surface end),
TextPrimary = token(function(p) return p.TextPrimary end),
TextMuted = token(function(p) return p.TextMuted end),
Accent = token(function(p) return p.Accent end),
Border = token(function(p) return p.Border end),
},
}

애니메이션은 state:Apply(q.Animate{...})로 붙인다

섹션 제목: “애니메이션은 state:Apply(q.Animate{...})로 붙인다”

q.Tween{...}한 번의 목표값을 서술하는 옵션 테이블이고, 그 Value는 plain 값이어야 합니다(State를 넣으면 에러입니다). 값이 바뀔 때마다 자동으로 보간되게 하려면 State에 q.Animate:Apply합니다.

  • 옵션 이름은 Time / Style / Direction / RepeatCount / Reverses / DelayTime / Override(“Cancel” 또는 “Finish”) / Dedup / CanAnimate이고, InfoTweenInfo를 통째로 줄 수도 있습니다.
  • 옵션에도 State를 넣을 수 있지만 옵션 State가 바뀌었다고 다시 애니메이션하지는 않습니다 — 다음 값 변경 때 최신 옵션이 반영됩니다.
  • CanAnimate = false면 보간 없이 값이 그대로 나갑니다(모션 축소 옵션에 쓰기 좋습니다).

3. Modifier.Overridden으로 스타일 합성

섹션 제목: “3. Modifier.Overridden으로 스타일 합성”

Modifier불변입니다 — 모든 setter는 바깥 테이블과 필드 테이블을 둘 다 얕게 복제해서 새 Modifier를 만들고, 만들어진 Modifier는 얼어 있습니다. 그래서 공용 기본 스타일을 컴포넌트에 넘겨도 원본이 오염되지 않습니다.

-- Styles.luau
return {
Button = D.Modifier.TextButton()
:Size(UDim2.new(0, 120, 0, 40))
:BackgroundColor3(Theme.Tokens.Accent)
:BorderSizePixel(0),
}

초기 필드 테이블 형태도 같은 값입니다 — D.Modifier.TextButton { Size = ... }.

-- ThemedButton.luau
local function ThemedButton(props: {
read Text: string?,
read OnClick: () -> (),
read Modifier: DTypes.IntoTextButton?,
})
-- 호출자 오버라이드가 있으면 기본 스타일 위에 얹는다(뒤가 이긴다)
local effective = if props.Modifier
then q.Modifier.Overridden(Styles.Button, props.Modifier:AsTextButton())
else Styles.Button
return D.TextButton {
effective,
Text = props.Text or "",
TextColor3 = Color3.fromRGB(255, 255, 255),
FontFace = Font.fromEnum(Enum.Font.GothamBold),
TextSize = 14,
Activated = function()
props.OnClick()
end,
}
end

글꼴은 FontFace로 쓰세요. 예제가 전부 FontFace = Font.fromEnum(...)인 이유는 그게 현행 API이기 때문입니다. [2026-09-09]Font = Enum.Font.GothamBold도 생성 D에 있어 타입 검사를 통과하지만, 엔진이 Hidden으로 표시한 레거시라 필드에 -- @deprecated 주석이 붙어 있습니다 — quad v1 코드를 옮기는 동안에만 쓰고 새 코드에는 FontFace를 쓰세요.

  • Modifier.Overridden(a, b, ...)필드 단위로 합치고 뒤 인자가 이깁니다. 닷 형태와 콜론 형태(a:Overridden(b))가 같은 함수입니다.
  • props로 받은 ModifierIntoTextButton 인터페이스로 받고 :AsTextButton()으로 검사형 하강을 합니다 — 상위 클래스 Modifier (D.Modifier.GuiObject())도 그대로 들어옵니다. mod:As<<T>>()무검사 캐스트이니 필요할 때만 쓰세요.
  • Modifier는 배열 부분에 놓습니다. 선택적이면 props.Modifier or q.None 관용구를 씁니다.

local function SettingsCard()
return D.Frame {
Size = UDim2.new(0, 360, 0, 240),
Position = UDim2.new(0.5, -180, 0.5, -120),
BackgroundColor3 = Theme.Tokens.Surface,
BorderSizePixel = 0,
UICorner = UDim.new(0, 12), -- 숏핸드: 관리 자식 UICorner를 만들어 붙인다
D.TextLabel {
Size = UDim2.new(1, -40, 0, 30),
Position = UDim2.new(0, 20, 0, 20),
BackgroundTransparency = 1,
FontFace = Font.fromEnum(Enum.Font.GothamBold),
TextSize = 18,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = Theme.Tokens.TextPrimary,
Text = "화면 설정",
},
D.TextLabel {
Size = UDim2.new(1, -40, 0, 20),
Position = UDim2.new(0, 20, 0, 55),
BackgroundTransparency = 1,
FontFace = Font.fromEnum(Enum.Font.Gotham),
TextSize = 13,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = Theme.Tokens.TextMuted,
Text = "테마와 표시 옵션을 바꿉니다.",
},
ThemedButton {
Text = "테마 전환",
OnClick = Theme.Toggle,
Modifier = D.Modifier.TextButton()
:Position(UDim2.new(0, 20, 1, -60))
:Size(UDim2.new(0, 140, 0, 38)),
},
}
end

Parent는 props가 아닙니다 — 만든 뒤 밖에서 card.Parent = screenGui 한 줄로 붙입니다.


  1. 토큰 갱신 경로가 하나입니다. isDark:Set만 바뀌면 그 토큰을 쓰는 모든 프로퍼티가 같은 경로로 갱신됩니다.
  2. 전환이 부드럽습니다. 각 토큰이 :Apply(q.Animate{...})를 거치므로 색이 튀지 않고 보간됩니다.
  3. 스타일 격리가 구조적으로 보장됩니다. Modifier가 불변이라, 커스터마이즈 한 사본을 넘겨도 공용 원본이 바뀌지 않습니다.